Merge master into agent harness branch
Integrate shared agent sessions with the current node transports, terminal persistence, folder UI, dependency pins, and shared diff control. Preserve grant attenuation, refresh host grants when execution settings change, and remove the agent transport import cycle.
Validation: 164 focused source tests passed; merged Sigil modules and inline JavaScript parse successfully. Combined builds and browser gates remain for the deployment worker.
.testignore | 12 +
README.md | 134 +++++++++
design/node-dialing-v1.md | 923 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
design/slate-git-wire-v1.md | 149 ++++++++++
docs/mobile-terminal-input.md | 86 ++++++
docs/predictive-terminal-echo.md | 58 ++++
docs/terminal-build-baseline.md | 30 ++
docs/terminal-copy.md | 84 ++++++
docs/terminal-reattach.md | 43 +++
package.sgl | 117 +++++++-
packages/slate-cli/design/terminal-lifetime.md | 63 ++++
packages/slate-cli/docs/sigil-http-streaming-integration.md | 8 +-
packages/slate-cli/package.sgl | 40 +--
packages/slate-cli/src/slate-cli/agent/ceiling.sgl | 108 +++++++
packages/slate-cli/src/slate-cli/agent/host.sgl | 20 +-
packages/slate-cli/src/slate-cli/agent/hub.sgl | 83 +++++-
packages/slate-cli/src/slate-cli/agent/loop.sgl | 17 +-
packages/slate-cli/src/slate-cli/agent/openai-completions.sgl | 2 +-
packages/slate-cli/src/slate-cli/agent/openai/oauth.sgl | 2 +-
packages/slate-cli/src/slate-cli/agent/openai/responses.sgl | 2 +-
packages/slate-cli/src/slate-cli/agent/service.sgl | 12 +-
packages/slate-cli/src/slate-cli/agent/tools.sgl | 32 ++-
packages/slate-cli/src/slate-cli/config.sgl | 77 ++++-
packages/slate-cli/src/slate-cli/dispatch.sgl | 177 +++++++++---
packages/slate-cli/src/slate-cli/enclave-node-init.sgl | 25 +-
packages/slate-cli/src/slate-cli/enclave-node-session.sgl | 224 +++++++++------
packages/slate-cli/src/slate-cli/enclave-remote.sgl | 68 +++--
packages/slate-cli/src/slate-cli/enclave-stream.sgl | 290 ++++++++++++++++---
packages/slate-cli/src/slate-cli/git/branch.sgl | 122 ++++++++
packages/slate-cli/src/slate-cli/git/diff.sgl | 325 +++++++++++++++++++++
packages/slate-cli/src/slate-cli/git/exec.sgl | 134 +++++++++
packages/slate-cli/src/slate-cli/git/log.sgl | 90 ++++++
packages/slate-cli/src/slate-cli/git/mutation.sgl | 347 ++++++++++++++++++++++
packages/slate-cli/src/slate-cli/git/status.sgl | 494 ++++++++++++++++++++++++++++++++
packages/slate-cli/src/slate-cli/http/stream.sgl | 8 -
packages/slate-cli/src/slate-cli/httpfetch.sgl | 19 +-
packages/slate-cli/src/slate-cli/listen.sgl | 52 +++-
packages/slate-cli/src/slate-cli/logging.sgl | 4 +-
packages/slate-cli/src/slate-cli/main.sgl | 1673 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
packages/slate-cli/src/slate-cli/node-posture.sgl | 66 +++++
packages/slate-cli/src/slate-cli/node/dial.sgl | 76 +++++
packages/slate-cli/src/slate-cli/node/direct.sgl | 645 +++++++++++++++++++++++++++++++++++++++++
packages/slate-cli/src/slate-cli/node/gateway.sgl | 343 ++++++++++++++++++++++
packages/slate-cli/src/slate-cli/node/pairing-carrier.sgl | 72 +++++
packages/slate-cli/src/slate-cli/node/pairing-client.sgl | 48 ++++
packages/slate-cli/src/slate-cli/node/pairing-control.sgl | 156 ++++++++++
packages/slate-cli/src/slate-cli/node/pairing-management.sgl | 63 ++++
packages/slate-cli/src/slate-cli/node/pairing.sgl | 294 +++++++++++++++++++
packages/slate-cli/src/slate-cli/persistent.sgl | 527 ++++++++++++++++++++++++++++++++++
packages/slate-cli/src/slate-cli/proxy.sgl | 49 ++--
packages/slate-cli/src/slate-cli/registry.sgl | 17 +-
packages/slate-cli/src/slate-cli/resource/transfer.sgl | 165 +++++++++++
packages/slate-cli/src/slate-cli/resource/transfer/connection.sgl | 96 +++++++
packages/slate-cli/src/slate-cli/resource/transfer/file.sgl | 203 +++++++++++++
packages/slate-cli/src/slate-cli/sessions.sgl | 237 ++++++++++++---
packages/slate-cli/src/slate-cli/terminal/discovery.sgl | 60 ++++
packages/slate-cli/src/slate-cli/terminal/ipc.sgl | 59 ++++
packages/slate-cli/src/slate-cli/terminal/owner.sgl | 355 +++++++++++++++++++++++
packages/slate-cli/src/slate-cli/wire.sgl | 66 +++++
packages/slate-cli/test/helpers/relay-peer.cjs | 60 ++++
packages/slate-cli/test/helpers/terminal-client.cjs | 82 ++++++
packages/slate-cli/test/terminal-restart-gate.cjs | 209 ++++++++++++++
packages/slate-cli/test/test-agent-ceiling.sgl | 244 ++++++++++++++++
packages/slate-cli/test/test-enclave-node-init.sgl | 122 +++++++-
packages/slate-cli/test/test-enclave-remote.sgl | 27 +-
packages/slate-cli/test/test-enclave-stream-resource.sgl | 211 ++++++++++++++
packages/slate-cli/test/test-git-branches.sgl | 143 ++++++++++
packages/slate-cli/test/test-git-diff-contract.sgl | 174 +++++++++++
packages/slate-cli/test/test-git-discard.sgl | 64 +++++
packages/slate-cli/test/test-git-file-mutations.sgl | 175 ++++++++++++
packages/slate-cli/test/test-git-hunk-mutations.sgl | 175 ++++++++++++
packages/slate-cli/test/test-git-log.sgl | 75 +++++
packages/slate-cli/test/test-git-node-contract.sgl | 423 +++++++++++++++++++++++++++
packages/slate-cli/test/test-http-stream.sgl | 2 +-
packages/slate-cli/test/test-main-router.sgl | 21 ++
packages/slate-cli/test/test-node-dial.sgl | 72 +++++
packages/slate-cli/test/test-node-direct.sgl | 283 ++++++++++++++++++
packages/slate-cli/test/test-node-gateway.sgl | 214 ++++++++++++++
packages/slate-cli/test/test-node-paired-direct.sgl | 158 ++++++++++
packages/slate-cli/test/test-node-pairing-control.sgl | 84 ++++++
packages/slate-cli/test/test-node-pairing-management.sgl | 53 ++++
packages/slate-cli/test/test-node-pairing-readers.sgl | 142 +++++++++
packages/slate-cli/test/test-node-pairing.sgl | 85 ++++++
packages/slate-cli/test/test-pairing-threats.sgl | 2 +
packages/slate-cli/test/test-persistent.sgl | 364 +++++++++++++++++++++++
packages/slate-cli/test/test-proxy.sgl | 2 +-
packages/slate-cli/test/test-reaper.sgl | 6 +-
packages/slate-cli/test/test-resource-plane.sgl | 79 ++++-
packages/slate-cli/test/test-revoke.sgl | 18 ++
packages/slate-cli/test/test-sessions.sgl | 108 ++++++-
packages/slate-cli/test/test-terminal-discovery.sgl | 133 +++++++++
packages/slate-cli/test/test-terminal-owner.sgl | 85 ++++++
packages/slate-cli/test/test-transfer-file.sgl | 93 ++++++
packages/slate-cli/test/test-transfer.sgl | 86 ++++++
packages/slate-netem/package.sgl | 11 +-
packages/slate-netem/scenarios/node-direct-loss.scn | 23 ++
packages/slate-netem/scenarios/node-transfer-loss.scn | 20 ++
packages/slate-netem/sigil.lock | 42 +--
packages/slate-netem/slate-netem.sgl | 5 +
packages/slate-netem/src/slate-netem/main.sgl | 13 +-
packages/slate-shell/package.sgl | 15 +-
packages/slate/assets/folder-browser.mjs | 68 +++++
packages/slate/assets/index.html | 1969 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
packages/slate/assets/sw.js | 16 +-
packages/slate/assets/terminal-compose.mjs | 221 ++++++++++++++
packages/slate/assets/terminal-copy.mjs | 351 +++++++++++++++++++++++
packages/slate/assets/terminal-prediction.mjs | 109 +++++++
packages/slate/package.sgl | 45 ++-
packages/slate/scripts/deploy-pwa-r2.sh | 2 +-
packages/slate/sigil.lock | 62 ++--
packages/slate/src/slate/accessory.sgl | 106 +++++--
packages/slate/src/slate/agent/remote.sgl | 16 +-
packages/slate/src/slate/bindings.sgl | 1 +
packages/slate/src/slate/boot.sgl | 12 +-
packages/slate/src/slate/commands/appearance.sgl | 4 +
packages/slate/src/slate/commands/folder.sgl | 94 ++++--
packages/slate/src/slate/commands/git.sgl | 629 ++++++++++++++++++++++++++++++++++++++++
packages/slate/src/slate/commands/git/branch.sgl | 242 ++++++++++++++++
packages/slate/src/slate/commands/help.sgl | 15 +-
packages/slate/src/slate/commands/navigation.sgl | 37 +++
packages/slate/src/slate/commands/project.sgl | 2 +-
packages/slate/src/slate/commands/search.sgl | 6 +-
packages/slate/src/slate/commands/terminal.sgl | 107 ++++++-
packages/slate/src/slate/components/diff.sgl | 147 ++++++++++
packages/slate/src/slate/enclave-account/session.sgl | 2 +-
packages/slate/src/slate/findfile.sgl | 66 +++--
packages/slate/src/slate/folder/enrichment.sgl | 202 +++++++++++++
packages/slate/src/slate/folder/model.sgl | 107 +++++++
packages/slate/src/slate/git/diff-model.sgl | 149 ++++++++++
packages/slate/src/slate/git/diff-view.sgl | 45 +++
packages/slate/src/slate/git/log-model.sgl | 134 +++++++++
packages/slate/src/slate/git/log-view.sgl | 56 ++++
packages/slate/src/slate/git/model.sgl | 354 +++++++++++++++++++++++
packages/slate/src/slate/git/review.sgl | 115 ++++++++
packages/slate/src/slate/git/status-view.sgl | 247 ++++++++++++++++
packages/slate/src/slate/ledge.sgl | 6 +-
packages/slate/src/slate/list.sgl | 6 +-
packages/slate/src/slate/main.sgl | 652 ++++++++++++++++++++++++++++++++++--------
packages/slate/src/slate/main/preferences.sgl | 5 +
packages/slate/src/slate/modes/folder.sgl | 335 ++++++++++++++++------
packages/slate/src/slate/modes/git-diff.sgl | 75 +++++
packages/slate/src/slate/modes/git-log.sgl | 175 ++++++++++++
packages/slate/src/slate/modes/git-status.sgl | 399 ++++++++++++++++++++++++++
packages/slate/src/slate/modes/node-status.sgl | 189 ++++++++++++
packages/slate/src/slate/modes/terminal.sgl | 248 ++++++++++++++--
packages/slate/src/slate/modes/welcome.sgl | 6 +-
packages/slate/src/slate/prompt.sgl | 36 ++-
packages/slate/src/slate/prompt/candidates.sgl | 4 +-
packages/slate/src/slate/provider.sgl | 20 +-
packages/slate/src/slate/providers/enclave-node.sgl | 1363 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
packages/slate/src/slate/providers/enclave-node/carrier.sgl | 234 +++++++++++++++
packages/slate/src/slate/providers/enclave-node/resource.sgl | 104 +++++++
packages/slate/src/slate/providers/node.sgl | 269 +++++++++++++----
packages/slate/src/slate/providers/slate-meta.sgl | 2 +-
packages/slate/src/slate/resource/transfer.sgl | 294 +++++++++++++++++++
packages/slate/src/slate/root.sgl | 39 ++-
packages/slate/src/slate/selector.sgl | 16 +-
packages/slate/src/slate/state.sgl | 9 +-
packages/slate/src/slate/theme.sgl | 23 +-
packages/slate/src/slate/tmux/ladder.sgl | 21 +-
packages/slate/src/slate/ui/diff.sgl | 19 +-
packages/slate/test/agent-session-test.sgl | 8 +-
packages/slate/test/agent-session-ui-browser-gate.mjs | 2 +-
packages/slate/test/appearance-command-test.sgl | 8 +-
packages/slate/test/client-update-policy-test.mjs | 35 +++
packages/slate/test/diff-control-test.sgl | 71 +++++
packages/slate/test/enclave-account-session-test.sgl | 17 ++
packages/slate/test/enclave-node-carrier-resource-test.sgl | 341 ++++++++++++++++++++++
packages/slate/test/enclave-node-carrier-session-test.sgl | 180 ++++++++++++
packages/slate/test/enclave-node-carrier-test.sgl | 133 +++++++++
packages/slate/test/enclave-node-kind-test.sgl | 16 ++
packages/slate/test/enclave-node-reattach-tick-test.sgl | 48 +++-
packages/slate/test/enclave-remote-live.sgl | 22 ++
packages/slate/test/findfile-test.sgl | 38 ++-
packages/slate/test/folder-command-test.sgl | 83 +++++-
packages/slate/test/folder-display-test.sgl | 8 +-
packages/slate/test/folder-image-relay-browser-live.mjs | 158 ++++++++++
packages/slate/test/folder-redesign-browser-gate.mjs | 462 ++++++++++++++++++++++++++++++
packages/slate/test/folder-redesign-test.sgl | 203 +++++++++++++
packages/slate/test/git-branch-browser-gate.mjs | 98 +++++++
packages/slate/test/git-branch-command-test.sgl | 125 ++++++++
packages/slate/test/git-contract-gate.mjs | 255 +++++++++++++++++
packages/slate/test/git-contract-v1.json | 63 ++++
packages/slate/test/git-diff-browser-gate.mjs | 4 +
packages/slate/test/git-diff-view-test.sgl | 110 +++++++
packages/slate/test/git-discard-refresh-browser-gate.mjs | 8 +
packages/slate/test/git-hunk-browser-gate.mjs | 8 +
packages/slate/test/git-log-browser-gate.mjs | 105 +++++++
packages/slate/test/git-log-test.sgl | 107 +++++++
packages/slate/test/git-mutation-browser-gate.mjs | 8 +
packages/slate/test/git-navigation-browser-gate.mjs | 93 ++++++
packages/slate/test/git-provider-routing-test.sgl | 30 ++
packages/slate/test/git-review-browser-gate.mjs | 141 +++++++++
packages/slate/test/git-review-command-test.sgl | 166 +++++++++++
packages/slate/test/git-status-browser-gate.mjs | 867 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
packages/slate/test/git-status-model-test.sgl | 270 ++++++++++++++++++
packages/slate/test/git-status-view-test.sgl | 383 +++++++++++++++++++++++++
packages/slate/test/list-test.sgl | 3 +
packages/slate/test/local-root-identity-test.sgl | 95 ++++++
packages/slate/test/navigation-command-test.sgl | 1 +
packages/slate/test/node-companion-restart-live.sgl | 121 ++++++++
packages/slate/test/node-direct-live.sgl | 311 ++++++++++++++++++++
packages/slate/test/node-direct-reattach-driver-test.mjs | 30 ++
packages/slate/test/node-multi-root-live.sgl | 122 ++++++++
packages/slate/test/node-network-transition-live.sgl | 123 ++++++++
packages/slate/test/node-paired-offline-invalidate-live.sgl | 88 ++++++
packages/slate/test/node-paired-offline-live.sgl | 112 ++++++++
packages/slate/test/node-paired-offline-multi-root-live.sgl | 70 +++++
packages/slate/test/node-paired-offline-reject-live.sgl | 39 +++
packages/slate/test/node-pairing-seed-live.sgl | 50 ++++
packages/slate/test/node-provider-test.sgl | 27 +-
packages/slate/test/node-registry-cache-test.sgl | 20 ++
packages/slate/test/node-status-view-test.sgl | 130 +++++++++
packages/slate/test/node-target-restart-live.sgl | 104 +++++++
packages/slate/test/node-transfer-loss-live.sgl | 137 +++++++++
packages/slate/test/performance-capture-policy-test.mjs | 185 ++++++++++++
packages/slate/test/preferences-store-test.sgl | 9 +-
packages/slate/test/profile-git-diff.mjs | 188 ++++++++++++
packages/slate/test/prompt-test.sgl | 21 ++
packages/slate/test/provider-replay-test.sgl | 8 +
packages/slate/test/resource-transfer-test.sgl | 89 ++++++
packages/slate/test/root-browser-gate.mjs | 25 +-
packages/slate/test/root-test.sgl | 17 ++
packages/slate/test/searchable-test.sgl | 8 +-
packages/slate/test/selector-test.sgl | 16 ++
packages/slate/test/state-test.sgl | 2 +
packages/slate/test/term-diff-test.sgl | 4 +-
packages/slate/test/term-test.sgl | 2 +-
packages/slate/test/terminal-compose-browser-gate.mjs | 218 ++++++++++++++
packages/slate/test/terminal-copy-browser-gate.mjs | 343 ++++++++++++++++++++++
packages/slate/test/terminal-copy-test.mjs | 19 ++
packages/slate/test/terminal-geometry-browser-gate.mjs | 200 +++++++++++++
packages/slate/test/terminal-mobile-input-policy-test.mjs | 33 +++
packages/slate/test/terminal-prediction-browser-gate.mjs | 255 +++++++++++++++++
packages/slate/test/terminal-prediction-test.mjs | 175 ++++++++++++
packages/slate/test/terminal-reattach-browser-gate.mjs | 179 ++++++++++++
packages/slate/test/terminal-resize-browser-probe.mjs | 148 ++++++++++
packages/slate/test/terminal-resize-policy-test.mjs | 53 ++++
packages/slate/test/terminal-test.sgl | 50 ++++
packages/slate/test/terminal-tmux-prediction-fixture.mjs | 43 +++
packages/slate/test/tmux-ladder-test.sgl | 6 +
packages/slate/test/wake-lock-policy-test.mjs | 21 ++
packages/slate/test/web-package-contract-test.sgl | 14 +
probes/local/http-body/check.cjs | 87 ++++++
probes/local/http-body/server.sgl | 14 +
release/README.md | 46 +++
release/allowed_signers | 1 +
release/cli-v0.1.3.md | 16 ++
scripts/check-git-contract | 23 ++
scripts/check-git-diff-view | 53 ++++
scripts/check-git-discard-refresh | 27 ++
scripts/check-git-file-mutations | 48 ++++
scripts/check-git-history | 20 ++
scripts/check-git-hunk-mutations | 45 +++
scripts/check-git-inline-diff | 39 +++
scripts/check-git-node-contract | 65 +++++
scripts/check-git-status-model | 54 ++++
scripts/check-git-status-view | 24 ++
scripts/check-hooks-browser-gates | 3 +-
scripts/local-release.sgl | 82 ++++++
scripts/release.sgl | 316 ++++++++++++++++++++
scripts/run-slate-netem | 6 +
scripts/test-enclave-remote-live | 6 +-
scripts/test-node-direct-live | 1031 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
sigil.lock | 148 +++++-----
265 files changed, 33777 insertions(+), 1406 deletions(-)A merge. Shown against its first parent, so this is the effect of merging rather than the work of the branch.
.testignoreadded
# Files under test/ that are NOT part of the default suite.## `sigil test` (0.21.0+) fails when a .sgl file in a test directory registers# tests and discovery did not select it, on the reasoning that such a file has# never run and nothing said so. These are the deliberate exceptions.# Live gates. Each runs only under its own script in scripts/, against a running# node or enclave, and reads required endpoints and credentials from the# environment. They error rather than skip when that environment is absent, so# they cannot join the default suite.packages/slate/test/*-live.sglpackages/slate/test/r2b-*.sglREADME.mdmodified
`slate launch` binds a loopback port (probing `32640`-`32643`), proxies the app from useslate.org onto that origin, connects with full local access (it silent-pairs on the loopback bind, no prompt), and opens Slate in a native window. It runs like a single program: **close the window and `slate` exits**; press `Ctrl-C` on `slate` and the window closes. With no `slate-shell` on `PATH`, it opens your browser in app mode instead. Proxy mode needs a network connection.### Persistent Android/Termux nodeFrom Termux, install `termux-services` and `curl`, then install the currentAndroid Slate binary as a supervised loopback node:```shpkg install termux-services curlslate node install --persistent```The node listens only at `http://127.0.0.1:32640/`. The installer verifies`/node/health`, opens the browser after a successful interactive install, andwrites a Termux:Boot entry that starts silently on boot. Install the[Termux:Boot](https://github.com/termux/termux-boot) app from the same app sourceas Termux, then open it once so Android enables its boot receiver.Reliable automatic startup also requires the operating system to permit both**Termux** and **Termux:Boot** to run in the background. Where the device exposessuch controls, enable background/autostart execution and set battery use tounrestricted (or the equivalent) for both apps. Vendor task managers differ andmay otherwise suppress the boot event even when the boot script is correct; see[Don't kill my app](https://dontkillmyapp.com/) for general, vendor-organizedbackground-process guidance. A wake lock is separate and remains opt-in.```shslate node statusslate node status --jsonslate node logsslate node stopslate node startslate node restartslate node uninstall```Choose the app only while the node is stopped. `staging` selects`https://next.useslate.org`, `production` selects `https://useslate.org`, andan explicit value must be an HTTPS URL:```shslate node stopslate node app stagingslate node start```Re-running `slate node install --persistent` is an idempotent upgrade. Uninstallremoves Slate's service, boot entry, control command, and lifecycle records whilepreserving user state, logs, and installed versions under`~/.local/share/slate/node`.### Persistent desktop/Guix nodeOn desktop Unix, the same command installs the current executable into Slate'sversioned node directory and maintains the stable CLI at `~/.local/bin/slate`:```shslate node install --persistent --name my-node```The stable launcher follows `~/.local/share/slate/node/active`, so shells andShepherd services should invoke `~/.local/bin/slate node run` and do not need tochange between releases. Desktop installation retains the normal XDG statelocation, preserving existing enrollment and pairings. Guix Home remainsdeclarative: the installer does not edit or activate the user's Shepherd graph.### Persistent OpenBSD nodeInstall from a non-root login account with a working `doas` policy. The samecommand installs the versioned binary and stable launcher, generates a native`rc.d` service, enables it, and starts or restarts it idempotently:```shslate node install --persistent --name my-nodercctl check slate_node```The system service runs as the invoking user, logs through `daemon.info`, andtracks Slate's stable launcher across atomic version changes. Running theinstaller as root is refused because a Slate node exposes that account's fileand terminal capabilities.`slate node status --json` is the stable machine-readable lifecycle view. Itreports installation, runit availability/state, the exact Slate child PID andliveness, loopback health, selected app URL, boot entry, last start/exit,restart count, and Slate's wake-lock choice as independent fields. Missing orcorrupt lifecycle records remain visible rather than being treated as stopped.Android/OEM autostart, background, and battery-exemption settings are reportedas `unknown`; when a boot entry exists the overall Android posture is only`user-managed`, never proof that Android will execute or preserve the service.The same versioned, read-only status contract is available from a running localnode at `GET /node/status`. Its common `platform`, `installation`, `supervision`,`process`, `listener`, `lifecycle`, and `autostart` sections apply equally toforeground or supervised desktop nodes and Android/Termux nodes. Platform-onlyfacts live below `platform-details`; absence of a desktop service manager is avalid foreground posture, not a failed node. The endpoint is served only by thenode's existing host-gated origin, accepts only GET, and is never cached.Schema version 2 also identifies the runtime role as `local-node` and reportsresource, session, agent, renderer, and remote-transport-manager capabilityposture. Capability availability describes what the node implements; it doesnot grant the current origin permission. Effective origin grants remain on theauthenticated `sys/info` surface. Remote transport management is explicitlyreported as unavailable and is not implemented by this local-node slice.Schema version 3 adds read-only node-upgrade posture. It reports the runningversion, the same-origin release-check endpoint, and the installation strategyfor the current platform. Termux uses atomic version directories; desktopinstallation remains package-managed. Upgrade apply and rollback are explicitlyunavailable until artifact verification, staged replacement, restart, andrecovery behavior are implemented.Release tooling lives in `scripts/release.sgl` and uses `(sigil shell)` forchecked external commands. `build-dry-run` prints the complete nine-artifactmatrix without writing; `build` requires a clean tracked checkout and an exactmatch with the declared CLI version. Both build modes also require the publishedSigil 0.20.3 and sigil-websocket 0.10.8 lock floors and reject path/raw-commitdependencies in shipping manifests; `dev-redirects.sgl` is development-only.`publish-dry-run` prints staging R2 keys,cache headers, stable-download redirects, and publication order without remotemutation. `publish` uploads only to `SLATE_RELEASE_STAGING_PREFIX` and publishes`/cli/latest.json` last. It requires `SLATE_RELEASE_BUCKET`; credentials andaccount-specific R2 details remain ambient to Wrangler and are never stored inthis repository. CLI manifests carry namespace-bound Ed25519 signatures made byOpenSSH; signing uses `SLATE_RELEASE_SIGNING_KEY`, and validation uses theexplicit `SLATE_RELEASE_ALLOWED_SIGNERS` policy.The matrix includes both conventional and fully static AMD64 Linux artifacts;the static artifact is the portable choice for Guix systems. It also includesnative amd64 and arm64 OpenBSD artifacts.The direct node-to-node transport baseline is documented in[`design/node-dialing-v1.md`](design/node-dialing-v1.md). The Enclave remains theauthenticated discovery/bootstrap plane and relay fallback; direct listenersbind only an explicitly selected VPN address.Notes:- **Native window opens in your browser instead?** Your Lantern predates the URL-app navigation fix. Run `sigil deps update` and rebuild `slate-shell`.design/node-dialing-v1.mdadded
# Node dialing v1## GoalSlate should use the Enclave as the authenticated discovery and bootstrap plane,then carry resource and terminal traffic directly between nodes whenever bothpeers can reach one another. The existing Enclave stream remains the fallback,so direct dialing improves latency without making a node unreachable.The first milestone is one authenticated session between two enrolled nodes,with an observable choice of `direct` or `relay`. Filesystem and terminaloperations continue to use the existing request, bulk-frame, session, credit,and grant contracts; transport selection must not create a second capabilitymodel.## Network boundary- A dialable listener binds to an explicitly selected WireGuard/VPN address, never `0.0.0.0` and never the public interface by implication.- The existing PWA-facing listener remains loopback-only. Direct node traffic gets a separate listener and authentication boundary.- A node advertises only an endpoint it has successfully bound. Absence of an endpoint means relay-only, not a guessed address.- The Enclave stores and distributes authenticated endpoint advertisements. It does not assert reachability; the dialing node measures that.## Identity and authentication- Enrollment creates the durable node identity. Display names are labels and are not transport credentials.- Endpoint advertisements are signed or MAC-bound to that enrolled identity and have a short expiry. A stale advertisement cannot authorize a connection.- The Enclave issues a single-use, short-lived rendezvous ticket naming both enrolled peers and the requested session. The accepting node verifies the ticket and that the connecting peer proves possession of its node identity.- Retired transport identities remain retired. A display name can be reused only through a fresh enrollment, as with `zerocool` and `0x11`.## AuthorizationDirect transport changes routing, not authority. After authentication, theaccepting node derives the same principal and applies the same origin/node grantsused by the relay path. `fs`, `proc`, `sys`, terminal session kinds, credit, andreattach semantics are identical on both transports. A direct connection thatcannot reproduce that principal is refused rather than granted a local default.## Selection and fallback1. Ask the Enclave for the target node's current endpoint and a rendezvous ticket.2. Make one bounded direct connection attempt.3. If authenticated direct setup succeeds, open the existing session protocol on it and report transport `direct`.4. On timeout, refusal, stale advertisement, or network failure, consume a fresh relay ticket and use the existing Enclave stream. Report transport `relay` and the non-secret fallback reason.5. Never migrate an established session silently in v1. A dropped direct session may be reattached through the existing durable-session mechanism.Direct failure is expected on sleeping phones and split VPN routes. It must notmark the node offline while its Enclave control connection is healthy.## Observability`sys/info` should advertise the selected VPN address, port, advertisement expiry,and direct-listener availability without exposing credentials. Session-open andreattach results should include `transport: "direct"|"relay"`; logs include thetarget node, phase, elapsed time, and a stable non-secret failure class. Statusmust distinguish control-plane connectivity, direct-listener health, and thetransport of each live session.## First implementation slice1. Add a separately configured VPN listener to the node and advertise it through the Enclave.2. Add rendezvous-ticket issuance and peer authentication.3. Carry one terminal session over the direct connection using the existing wire frames and grant resolution.4. Add bounded relay fallback and expose the selected transport in status/logs.5. Test direct success, sleeping/unreachable peer fallback, invalid ticket, wrong peer identity, expired advertisement, and reattach through relay.File operations need no separate transport design after this slice: they use thesame authenticated request and bulk lanes already proven by remote browsing.## Implementation statusThe first transport-selection seam lives in `(slate-cli node dial)`. It acceptsonly broker-supplied, unexpired endpoint advertisements, makes exactly onebounded authenticated direct attempt, and obtains relay fallback through aseparate callback that receives no direct credential. Results expose `direct`,`relay`, or `unavailable` plus a stable non-secret fallback class.The Enclave side now keeps connection-bound endpoint advertisements in ephemeralmemory and issues 15-second, peer-bound direct rendezvous material over the twoauthenticated node control connections. Those keys are never registered asrelay bearer tickets. The companion parses the resulting `DIRECT-TICKET`message and implements the mutual proof transcript. Broker rendezvous uses theauthenticated Enclave transport nick consistently for `DIRECT-OPEN`, bothticket halves, and the direct hello transcript. Friendly node labels and numericbot ids remain distinct presentation and durable-identity fields.`slate node run --direct-host <vpn-address> [--direct-port <port>]` now binds aseparate `/node/direct` WebSocket listener before advertising it. Wildcard andloopback addresses are refused. The advertisement is refreshed every 30 seconds,`sys/info` reports listener health and expiry without credentials, and theacceptor consumes its ticket only after the nonce-bound mutual proof succeeds.Authenticated frames then enter the existing Resource/Session handlers with thesame configured grant posture as relay.The caller-side companion gateway is now wired. The browser remote provideropens a managed carrier through its authenticated loopback node connection; thecompanion performs brokered direct rendezvous first and uses the already-armedEnclave stream as fallback. Production Resource and Session initiation thereforeshare the companion selector and report the transport that actually bound.The caller connector now lives in the existing `(slate-cli node direct)`submodule. It opens exactly the broker-named endpoint, completes the nonce-boundmutual proof under one deadline, returns the socket only after `direct/ok`, andcloses every failed attempt before relay acquisition can begin. Socket operationsare injected as a small transport record so the authentication and timeoutcontract is covered without a live VPN peer. The remaining gateway work is tomultiplex that authenticated carrier across the browser's loopback connection;remote frames cannot be injected into the already-active local-node frame space.Loopback multiplexing is defined by `(slate-cli node gateway)`. Each remotecarrier receives a companion-local unsigned 32-bit id and an explicit`opening`/`ready` lifecycle. Text messages use a `remote/text` control envelope;binary messages use `[0x11][u32 carrier id][original node frame]`, keeping theinner Resource/Session bytes opaque. Carrier removal precedes socket closure soa re-entrant close callback cannot route another frame onto a dying connection.The registry records only `direct` or `relay` and the stable fallback class inits observable fields; endpoint and credential material are not part of themultiplexing protocol.The loopback listener now recognizes authenticated `remote/open` and`remote/close` requests ahead of the local capability dispatcher. An open pinsthe carrier to that browser connection, derives a globally unique bounded`DIRECT-OPEN` correlation id from the carrier id, and permits the enrolled nodecontrol session—not the browser—to issue the broker request. A close from anyother browser connection is indistinguishable from a missing carrier, and lossof the owning loopback connection removes all of its pending carriers. Thesame ownership check gates every text and binary carrier frame.Caller `DIRECT-TICKET` completion now looks up the still-opening carrier by its`g<carrier>` request id and requires caller role plus exact peer identity. Theone-time ticket is consumed before the native WebSocket handshake begins. Asuccessful mutual proof binds the carrier as `direct` and emits a secret-free`remote/opened` event to its owning browser connection. A failed attempt movesthe carrier once to `opening-relay` and sends a new `stream/open` request throughEnclave using only the target identity and correlation id. Caller tickets use adaemon-wide inbox, so a node may dial directly without also advertising its owndirect listener. Relay-ticket response redemption and carrier frame pumping arenow wired as well: the response must match the target, request id, lifecycle,verb, and bounded ticket lifetime before the bearer is redeemed immediately.The bearer is never stored in carrier state. Ready direct carriers exchangeplain WebSocket text and opaque binary node frames; ready relay carriers wraptext in the existing Enclave stream control frame and leave binary node framesopaque. A bounded nonblocking pump returns inbound frames to the owning browserunder the same carrier id, and transport loss removes the carrier beforeemitting `remote/closed` so there is no silent mid-session migration.The browser half starts at `(slate providers enclave-node carrier)`, nestedunder the existing remote provider rather than registered as another provider.It owns only companion carrier lifecycle and the `remote/text`/`0x11` envelopecodec. `(slate providers node)` reserves those authenticated loopback envelopesfor an extension callback before its ordinary local-node dispatch, so localframes and remote carrier frames cannot collide. Direct and relay remaintransport choices beneath `node:` sources rather than new source providers.Carrier opens use the ordinary authenticated node request envelope, and thecompanion echoes its browser request id in every lifecycle event. This permitsconcurrent opens for different roots while same-root callers coalesce onto oneopening or ready carrier.Coarse remote Resource requests now use that managed carrier whenever theloopback companion is authenticated. The browser sends the existing node v2request envelope inside `remote/text`; inline `ok`/`err` replies return on thesame carrier, while bulk replies retain the existing `0x10` node frame insidethe gateway's `0x11` carrier frame. Both request id and carrier id must matchbefore a callback can settle. Carrier acquisition failure temporarily retriesthe request through `ENCLAVE-RPC`, preserving compatibility during migration;loss of an established carrier fails only work pinned to that carrier and doesnot silently change transports mid-request. Browser-local Resource ids occupythe lower half of u32 and carrier-open tickets the upper half, with numericcomparison across JSON decoding at the boundary.Fresh remote sessions now use the same per-root carrier. `session/open` remainsan ordinary node v2 control request inside `remote/text`; PTY input, output,resize, signal, credit, error, exit, and close frames retain their existingbinary format inside the gateway `0x11` envelope. The client extends its legacystream record with transport kind and carrier id, so channel binding, queuedinput flushing, detach handling, and replay accounting continue through onesession state machine. Closing one session retires only that session because acarrier may be shared by several sessions. Carrier loss retires and reports allsessions pinned to that carrier without disturbing sessions on another root.Initial carrier acquisition still falls back to the legacy Enclave stream.The bounded reattach ladder now treats either an authenticated companion or theEnclave account plane as an available acquisition path. A due attach obtains afresh managed carrier and sends `session/attach` with the durable session id;only the returned channel changes. Carrier acquisition failure falls back onceto the legacy `stream/open {sid}` path, and a synchronous fallback failureclears the in-flight mark immediately so the ladder cannot wedge. The existingattempt counter, backoff cap, definitive `dead`/`no-session` handling, and replayattribution remain shared by both transports.Loss of the authenticated browser-to-companion loopback is also a carrierfailure. `(slate providers node)` numbers transport installations and emits onegeneration-checked, idempotent disconnect notification; a stale close from anolder socket cannot tear down replacement work. The browser carrier registryatomically clears all opening and ready entries before notifying openingwaiters, allowing their compatibility fallback to create work in the newstate. The remote provider then fails only established carrier-backed Resourcerequests and removes only carrier-backed session streams before notifying theirconsumers. Legacy Enclave requests and streams are not part of this teardown.Each affected live session enters the ordinary bounded reattach ladder.### Local root selection`sys/info` also exposes the companion's opaque enrolled transport identity. Theclient compares that value with each discovered root's Enclave transport nick;display labels never participate. A match marks the registered root `local?:`and routes it through the loopback `local:` provider. The `local:` root remainsregistered as a compatibility alias for saved sources and typed paths, but ishidden from root navigation once the canonical registered name is available.The welcome page and root picker therefore show one registered machine name witha `local` annotation, not separate local and remote-looking entries for the samemachine.## Verified implementation checkpoint — 2026-09-01The first direct-dialing slice is complete and committed:- Slate `25b14c9` (`feat/node-dial-v1`) completes the companion gateway, direct-first selection, relay fallback, canonical direct-peer grant tables, browser carrier delivery, and the native two-phase live gate.- sigil-websocket `da7eeaf` keeps native `ws-receive-nowait` from parking on an unreadable plain socket. The earlier handshake scheduler fix is `12db076`.- Sigil `055c0f17` makes scheduler-owned TCP connects asynchronous and nonblocking, with focused regression coverage.- The Enclave rendezvous and relay changes are committed in its `feat/node-dial-v1` worktree; that worktree is clean.The live gate starts two enrolled nodes and proves both outcomes using the sameremote file request:1. With no advertised endpoint, the carrier selects `relay` and the bulk file response completes.2. After the same target identity restarts with an explicit VPN listener, the carrier completes mutual authentication, selects `direct`, and the same request completes.Final verdict: `GREEN — DIRECT SUCCESS AND RELAY FALLBACK PASSED`.### Current protocol boundaryDirect carriers enter the ordinary node listener and therefore support theexisting Resource and Session protocol, including grant enforcement and binarybulk frames. Relay holders now enact the same dispatch reply descriptors as theordinary listener: unary replies, bulk results, deferred `fs/write-file` bodies,and `fs/watch`/`fs/unwatch` subscriptions share the canonical grant checks andlocked connection sender. Pending write authority is single-use, watcher handlesare holder-owned and close with the stream, and unmatched or replayed bulkbodies fail closed without changing transport.The remote provider now persists over the deferred bulk lane and holds watchcallbacks against the selected managed carrier until explicit unwatch or carrierloss. A bulk write cannot fall back to legacy `ENCLAVE-RPC`, whose unary shapecannot carry its body; inability to acquire the authenticated companion carrieris an explicit error rather than a silent protocol migration.Focused relay coverage exercises successful writes, replay and unmatched-bodyrefusal, watch delivery/unwatch, continued connection usability, and grantboundaries. The two-phase live gate performs watch, deferred write, read-back,and unwatch over both relay and direct carriers while asserting the transport.Commit `45a7b6b` extends that gate through the Session plane. Both relay anddirect phases now open a real PTY with a deliberately bounded initial window,prove credit exhaustion and resumption, send input, resize the terminal, anddetach/reattach through a fresh session request while the selected transportremains explicit. The direct arm exposed an authorization split: Resourcedispatch used the mutually authenticated connection's cached grant table, but`session/open` rebuilt grants from browser-origin configuration and denied thenode peer. Session open now consumes the same cached authenticated grant table,with a focused direct-peer regression test and the real two-node gate green.The direct authentication suites already pin invalid role/TTL inputs, absoluteexpiry, single-use consumption, wrong peer transcript binding, wrong proof,and gateway request/peer/lifecycle correlation. Those focused security casesremain fail-closed; the outstanding item is a live adversarial handshake gate,not missing decision-function coverage.Commits `6313523` and `7b9a35d` close the direct-loss continuity slice. Amanaged carrier reattach carries its durable sid through `remote/open`; relayrouting first checks the direct listener's existing daemon hub and binds therelay adapter to that same hub, otherwise it asks holder children as before.It never spawns a replacement for a named sid. The reattached event reports theselected transport explicitly; managed carriers announce on their definitiveattach acknowledgement and leave replay bytes unknown when the loopback draincannot attribute them soundly.The live loss arm puts only the advertised direct WebSocket behind`slate-netem`. Its controls prove real traffic crossed the proxy, the emulatorclosed the established carrier, and a bounded direct retry was refused duringthe cut. The original PTY then reattaches through relay, replays output bufferedduring loss, accepts new input, and never emits the replacement-process marker.Together with the two baseline arms the gate ends`GREEN — DIRECT, RELAY, AND LOSS/REATTACH PASSED`.The live adversarial gate exercises the remaining direct authenticationboundaries through the genuine listener. It corrupts the caller proof, claims awrong caller identity, waits out the acceptor's 15-second ticket while retainingonly the dev caller's local copy, and completes then reconnects a consumedticket. Each attack records the explicit `authentication` outcome and the samefull Resource/Session probe proceeds only after selecting the already armedrelay. The replay arm separately proves its first genuine handshake connected,so the second attempt is demonstrably a replay. These hooks are inert unlessboth `SLATE_DEV=1` and a test-only selector are present; they cannot add anacceptance path. The expanded gate ends`GREEN — DIRECT, RELAY, LOSS/REATTACH, AND ADVERSARIAL AUTH PASSED`.The remote `node:` provider now exposes the dispatch plane's existing`fs/mkdir`, `fs/rename`, and recursive `fs/delete` verbs through the samecorrelated managed carrier used by reads, deferred writes, watches, andsessions. Focused coverage pins their exact paths and selected transport. Thelive gate creates, renames, and deletes a directory, verifying each durablestate through a fresh parent listing; it also proves a 2 MiB-plus-taildeferred write/read-back byte-for-byte on both relay and direct. After theexplicit `fs/unwatch` acknowledgement, a further write and more than one pollinterval produce no subscription event.This is deliberately the v1 unary-bulk boundary, not an implicit streamingclaim. Sigil-websocket now completes partial native socket writes whilepreserving encoded frame bytes, and both Slate and Enclave admit the resultingframe in bounded 64 KiB reads with an explicit 4 MiB fail-closed ceiling. Thelive gate proves a 2 MiB-plus-tail unary write/read-back over relay and direct.Crossing that ceiling now uses the explicit multi-frame Resource transfer lane.The node and relay holder share one atomic file adapter, and the renderer sharesone credited client state machine across the local socket and managed remotecarriers. Uploads switch at 3 MiB; file downloads negotiate the transfer laneup front. Frames remain ordered and bounded at 64 KiB with a 256 KiB initialcredit window, exact-length completion, cancellation, and owner-scoped cleanupwhen a local socket generation or remote carrier disappears. The renderer alsopublishes an optional acknowledged-byte progress callback; no UI subscribes toit yet. Transfers never migrate to legacy `ENCLAVE-RPC`.The live gate now crosses that boundary with a deterministic 5 MiB-plus-tailupload and download over both relay and direct carriers. It verifies exactround-trip bytes, monotonic acknowledged progress ending at the negotiatedlength, the selected transport, and an empty renderer transfer registry aftereach completion. Dedicated throttled direct-carrier arms then cut an upload anda download after several megabytes have crossed `slate-netem`. Each operationfails exactly once without completing or migrating, renderer state returns tozero, interrupted upload publication remains atomic, no sibling staging filesurvives, and a fresh request explicitly selects relay and succeeds. Netem's ownliveness and fault controls pass for both cuts.The same loss gate now routes the two relay `/stream` legs through a distinct,optional stream ingress while leaving both account/control sockets healthy.Throttled relay upload and download each cross several megabytes before netemcuts the active carrier sockets; both fail exactly once, leave no renderer orstaging state, and recover through a fresh relay request. Progress events recordthe measured transfer high-water marks on every topology: no frame exceeds 64KiB and credited/unacknowledged data never exceeds the 256 KiB window. Thiscompletes the multi-frame Resource transfer slice; it does not complete thebroader node-addressing proposal.The first lifecycle/concurrency slice is also green. Focused coverage now openstwo roots concurrently, completes their carrier negotiations out of order, andproves that closing one root settles only its own pending work while the othercarrier remains usable. A live relay-owned PTY survives a caller-companionrestart: the dead loopback generation reports loss once, the replacementgeneration authenticates and opens a fresh carrier, and the original holderreplays buffered output and accepts new input without spawning a replacementPTY. Carrier-backed reattach records give that normal companion reconnect onebounded grace turn before account-plane fallback, avoiding a deterministic racebetween the 1s reattach floor and the companion's 1.5s reconnect floor. This isnot evidence that a direct holder survives loss of its hub-side companion;durable hub-independent direct pairing remains part of the broader audit below.Target-companion restart is now bounded as a distinct, non-durable case. ItsPTY holder is `die-with-parent`, so the client reports one transport loss andmust not invent a replacement or a reattachment. A restart can race carrieracquisition before the replacement target has registered; the companiongateway now expires an opening carrier after five seconds instead of leaving itwedged in `opening-relay`. The existing account-plane fallback then obtains thetarget's definitive `no-session`, retires the ladder, and leaves zero pendingreattach records. The live gate establishes that terminal lifecycle. It alsoestablishes fresh recovery in a separate client lifetime against that samereplacement target: the complete relay probe opens a new carrier and exercisesPTY, filesystem, watch, and multi-frame transfer work successfully. Isolatedholder logging showed that the earlier apparent ticket-handshake stall wasactually the synthetic test scheduler waiting for two client/account lifetimesto tear down in one process. Holder children now receive request-scoped logpaths so the logging layer's emulated append cannot race the parent and erasetheir startup and handshake evidence. The terminal-lifecycle probe also wakesits parked account worker before `with-async` joins it, matching the establishedcleanup contract used by the other live probes.The network-transition arm is now explicit as well. A durable PTY first opensover a direct carrier routed through `slate-netem`; the direct lane is then cutwhile the Enclave control and relay paths remain healthy. The original handlereports exactly one loss and exactly one reattachment, the replacement carrieris visibly `relay`, buffered output and new input reach the original PTY, and asentinel proves no replacement process was spawned. The client observes onelive stream after reattach, then zero streams, pending requests, and reattachrecords after explicit close.Field diagnostics now preserve that same causal chain on real devices. Theacceptor distinguishes paired-context, paired-proof, broker-hello, broker-ticket,and broker-proof rejection without recording tickets, keys, nonces, or proofs.The companion records every established carrier closure with target, carrier,request, and selected transport. The client records stream loss, eligibility,ladder arming, the one-turn carrier grace, every attach issue and its selectedpath, synchronous issue failure, and successful reattachment with transport andhonestly attributable replay size. Together the caller and acceptor traces canseparate direct-auth failure from carrier loss and missing reattachment duringa mobile network transition.Netem independently proves that the direct WebSocket crossed the proxy, thatthe active carrier was cut, and that the bounded direct retry reached the cut.Focused carrier tests retain the complementary non-migration proof: loss failswork pinned to that carrier, and a late correlated reply cannot cross anothercarrier and resurrect it.Live concurrent multi-root coverage now matches that focused contract. Theharness enrolls two independent target identities with separate state,processes, ports, and logs, then keeps one direct carrier and one relay carrieropen at the same time. Both PTYs exchange independently marked input and output;closing the direct-root session reduces the stream registry from two entries toone while the relay-root PTY remains writable. Neither root reports a loss orerror, transport attribution remains per root, and explicit final close leaveszero streams and pending requests. The deterministic carrier suite remains theproof for deliberately reversed acquisition completion and late-replyrejection, which the real network does not promise to schedule in a fixedorder.### Hub-independent direct carrier ownershipDirect-companion restart requires a different process boundary from the onecurrently shipped. A connected WebSocket cannot be transferred safely afterthe companion has authenticated it, so the durable worker must own the directdial from its first network operation. This is carrier durability, not durablepairing: the Enclave remains the discovery and rendezvous authority, and theworker receives one already-authorized attempt rather than acquiring its ownaccount-plane identity.The implementation is divided into the following explicit phases:1. **Worker and local carrier protocol.** Add an internal `_direct-carrier` worker and a `(slate-cli node carrier-worker)` module. The companion creates a private Unix socket in its mode-0700 state directory, starts the worker without `die-with-parent`, and sends endpoint, target identity, rendezvous material, deadline, and stable non-secret attempt id over that socket. No ticket, proof key, endpoint credential, or adoption capability appears in argv, logs, or browser-visible state. The worker performs the existing `(slate-cli node direct)` handshake itself and owns the authenticated WebSocket thereafter. Browser `remote/text` and `0x11` frames remain opaque and are forwarded through the local protocol; the companion still owns the browser-facing carrier id and all policy about direct-versus-relay selection.2. **Bounded orphaning and adoption.** Once direct authentication succeeds, the worker atomically publishes a mode-0600 descriptor containing only its Unix socket path, pid, authenticated target identity, creation time, protocol version, and a random local adoption capability. Loss of the companion begins a 30-second orphan grace period; it does not make in-flight Resource work durable, extend session TTLs, or suppress target-socket failure. A replacement companion proves the descriptor capability in a nonce-bound challenge, with the existing same-uid Unix peer gate as a second condition. Successful adoption rotates the capability and atomically replaces the descriptor before accepting browser traffic. A failed, replayed, wrong-uid, wrong-target, expired, or concurrent adoption attempt closes only that local attempt. If grace expires, the worker closes the direct socket, removes its descriptor/socket, and exits.3. **Carrier remapping and session recovery.** Worker identity is never a browser carrier id. After restart, a fresh `remote/open` for the same target may adopt the worker and allocate a new companion-local u32 carrier id; stale ids from the dead companion remain permanently retired. Resource operations pinned to the lost loopback generation fail exactly once and are never replayed. Durable sessions enter the existing bounded reattach ladder and send `session/attach {sid}` over the newly mapped direct carrier. Only a successful attach emits `reattached: #t` with `transport: direct`; refusal or worker loss follows the existing relay/account-plane ladder and remains visible as a transport change.4. **Limits, cleanup, and observability.** Apply a separate per-node cap to direct carrier workers and reap exited workers before admission. Startup inventory removes descriptors only after a failed bounded connect/challenge; it must not unlink a socket merely because it predates the companion. Logs use request-scoped worker paths and record target, phase, elapsed time, adoption generation, session count if known, and stable failure class—never secrets or endpoint credentials. Normal browser carrier close, target close, authentication failure, grace expiry, and node shutdown each have one idempotent cleanup path.5. **Verification.** Pure protocol tests pin descriptor validation, challenge rotation/replay rejection, generation checks, grace expiry, carrier-id remapping, caps, and idempotent cleanup. The live gate opens a direct durable PTY, kills only the companion, starts its replacement inside the orphan grace, and proves exactly one loss plus one `reattached: direct` event, buffered output, new input reaching the original PTY, no replacement process, and zero residual carrier/session state after close. Separate arms cover adoption replay and grace expiry; neither may fall through silently to a purportedly preserved direct carrier.Showing the first 500 of 924 diff lines for this file. This diff is INCOMPLETE; read the file or clone the repository for the rest.
design/slate-git-wire-v1.mdadded
# Slate Git wire protocol v1Status: frozen by phase G0. This document and`packages/slate/test/git-contract-v1.json` are one contract; the executabledeclaration is the population authority used by `scripts/check-git-contract`.## Boundary and envelopeGit operations act on a Slate **source**, never on a client filesystem path.The source's registered root selects a provider and node destination. Local andremote node-backed roots use the same callback-shaped operation and wire bytes;only their destination differs. Browser-only roots report `git-unavailable`.Every request uses the existing correlated node lane:```json{"t":"req","id":41,"verb":"git/status","args":{"repo":{"root":"home","worktree":"/work/app","common_dir":"/work/app/.git"},"recent_limit":20}}```Every success is `{"t":"res","id":41,"ok":true,"result":...}`. Every erroris `{"t":"res","id":41,"ok":false,"error":{"code":"...","message":"bounded safe text"}}`.Unknown fields may be ignored within v1; missing required fields, wrong types,unknown enum values, and malformed cursors are `operation-failed` replies.The node validates the requested path/repository against the root's grant on**every** operation, canonicalizes discovery on the node machine, invokes Gitwith explicit argv (never a shell string), fixes locale and presentationsettings, and bounds time, stdout, and stderr. Operation logs contain the verb,correlation id, duration, outcome, destination, and a bounded path summary.They never contain patch bodies, file contents, credentials, or unboundedstderr.## Frozen resource limits| resource | v1 ceiling ||---|---:|| structured diff lines per reply | 400 || UTF-8 encoded diff result per reply | 262144 bytes || concurrent diff-page requests per view | 2 || mounted diff rows per view | 800 || recent commits in status | 50 || bounded error text | 4096 UTF-8 bytes |Both line and encoded-byte limits apply: a reply ends before either would beexceeded. A single logical line too large to fit is represented by boundedtruncation metadata, not transferred whole. Cursor tokens are opaque,repository/snapshot/diff-side scoped, and invalid after refresh or collapse.## IdentitiesThe node-side discovery result contains `node_repo: {worktree, common_dir}` plus`bare` and `object_format` (`sha1` or `sha256`). The selected provider/clientattaches its registered root to form `repo: {root, worktree, common_dir}`.`root` is mandatory in that qualified identity: identical paths on two rootsare different repositories. The node treats the name as opaque provenance andrevalidates both paths against its grants on every later request. Noclient-generated bare path is repository authority.`snapshot` is `{head_oid, index_id, worktree_id}`. The ids are opaque node-ownedgeneration inputs. Every mutation supplies the complete expected snapshot; thenode compares it immediately before changing anything. Mismatch returns`stale-snapshot` and changes neither index nor worktree.Status entries carry stable `id`, `path`, optional `original_path`, `kind`,`index_state`, `worktree_state`, conflict stages, and binary/mode/rename/submodule metadata. `binary` is `unknown`, `text`, or `binary`; status returns`unknown` because porcelain v2 does not inspect content, and an explicit lazydiff request resolves the classification. Stable ids are scoped to theroot-qualified repository and snapshot.Hunk identities are `{file_id, side, old_start, old_lines, new_start,new_lines, context_hash}`. They identify a candidate, not authoritative patchtext. A hunk mutation makes the node recompute the diff under the expectedsnapshot and reject stale or ambiguous candidates.## Verb populationThe v1 MINIMUM population contains seven verbs. Exact required request/resultfields and error arms live in the executable declaration.### `git/discover`Request: `{"path":"/work/app/src"}`. Result:```json{"version":1,"node_repo":{"worktree":"/work/app","common_dir":"/work/app/.git"},"bare":false,"object_format":"sha1"}```Discovery starts from the source/project directory on the owning node. Acanonical repository escaping the selected root is `grant-denied`.### `git/status`Request: `{"repo":REPO,"recent_limit":20}`. Result contains `version`, `repo`,`snapshot`, `branch`, `operation`, `entries`, `recent_commits`, and`capabilities`. Branch describes head/detached/unborn, upstream, ahead/behind.Operation names merge/rebase/cherry-pick/revert/bisect state or is null. Statuscontains inventory and summary metadata only: it MUST NOT compute, carry,parse, or render patch bodies.### `git/diff`Request includes `repo`, `snapshot`, `kind`, `target`, `cursor`, `max_lines`,and `max_bytes`. `kind` is one of `worktree-file`, `index-file`, `staged-all`,or `commit`; `target` is the stable file id or validated commit oid asappropriate. Result contains structured `files`, each with structured hunks andlines carrying old/new coordinates, plus `next_cursor`, `complete`, and`truncation`. Binary, rename, mode, and submodule changes remain structuredsummaries.No `git/diff` request occurs during status open. Pages start only after explicitexpansion/opening. A view runs at most two page requests concurrently and keepsat most 800 mounted rows. Collapse, close, repository switch, or refreshinvalidates the relevant generation; late replies cannot mutate current state.### `git/stage-file` and `git/unstage-file`Requests contain `repo`, `expected_snapshot`, and `entry_id`. The node resolvesthe path identity from its current status under that snapshot. Results contain`version`, `repo`, and a fresh `snapshot`; the client refreshes immediately.### `git/stage-hunk` and `git/unstage-hunk`Requests contain `repo`, `expected_snapshot`, and `hunk_id`. Rendered patchtext is never accepted. The node recomputes, selects, constructs, and appliesthe patch. Missing/ambiguous identity is `stale-snapshot`; unsafe partialoperations are `operation-failed` without mutation.## Error vocabulary- `not-a-repository`: the source is not contained in a Git repository.- `git-unavailable`: Git cannot run on the selected root/node.- `grant-denied`: a path or canonical repository escapes root authority.- `stale-snapshot`: expected repository state no longer matches; no mutation.- `conflict`: Git reports a conflict requiring explicit presentation/workflow.- `operation-failed`: malformed input/result, bounds, timeout, unsupported partial operation, or another bounded non-conflict failure.No other v1 error code crosses the node/client boundary.## Gate transcriptsGate and sabotage transcripts never live in the source tree. Executors andreviewers use a fresh directory such as`$XDG_CACHE_HOME/slate/git-contract-transcripts/<commit-or-run-id>/` or a`mktemp -d` directory under `/tmp`, record cache warmth and tool versions there,and copy the command, exit status, and complete output. The gate itself createsand removes fixture repositories under `mktemp -d`; it never writes beneath thecheckout.docs/mobile-terminal-input.mdadded
# Mobile terminal inputThe two-row key bar below the Ledge supports a local composer for terminals.Choose **Term → Compose Input**, or swipe left across the keys, to open it. Swipe right acrossits side controls, or tap the back arrow, to return to the keys. The text fieldkeeps native selection, clipboard editing, autocorrect and IME behavior; swipesinside the text field belong to native editing.The Term menu contains Compose Input, C-c, C-d, C-r, and Close. Esc, Tab, andarrows remain directly available in the key grid. The grid uses the full widthin every view, with no pencil column or empty placeholder.Typing in the composer sends no terminal input. Enter or Send sends only thecurrent text, without appending an Enter to the remote application. IMEconfirmation does not submit. Shift+Enter inserts a local newline. Sending usesthe terminal's existing paste path, including bracketed paste when enabled andnormalization of embedded newlines. Use the ordinary terminal keyboard tosubmit the resulting command separately.Drafts and selection are kept per terminal in browser memory, across redrawsand view switches. They do not persist through a PWA reload. A known disconnectedor closed terminal refuses submission and retains the draft with an error.Successful submission means acceptance by the local session writer, not a remotedelivery acknowledgment. Reconnection never automatically submits a draft.The last five successful submissions are also kept per terminal in browsermemory. When the composer is empty, a clock replaces Send. Tap it to see recenttext, then tap an entry to restore it for editing without sending it. The clockis disabled until there is history. Typing closes the list and protects the newdraft. Reloading the PWA clears both drafts and history.See [Terminal copying](terminal-copy.md) for frozen local selection and takingclipboard text received from tmux.Alt and Ctrl use the normal sticky-modifier reducer without a full view render.Their pointer handlers preserve the active native input or editor selection,cancel on swipes, and consume the compatibility click after a touch. One-shotmodifier highlights clear after the following key without moving focus.Implementation:- `packages/slate/assets/terminal-compose.mjs`: native draft state and events.- `packages/slate/src/slate/accessory.sgl`: key/composer markup.- `packages/slate/src/slate/main.sgl`: silent modifier and focused-target bridges.- `packages/slate/src/slate/modes/terminal.sgl`: guarded paste submission.- `packages/slate/assets/index.html`: host routing, focus and styling.Build with `guix shell binaryen -- sigil build slate --config web`, then run:```shnode packages/slate/test/terminal-compose-browser-gate.mjsnode packages/slate/test/terminal-copy-browser-gate.mjsnode packages/slate/test/terminal-copy-test.mjsnode packages/slate/test/terminal-prediction-browser-gate.mjsnode packages/slate/test/terminal-prediction-test.mjsnode packages/slate/test/terminal-mobile-input-policy-test.mjs```For a repeatable heavy-output and viewport-resize profile, run`node packages/slate/test/terminal-resize-browser-probe.mjs`. It compares liveand frozen terminals under 4× browser CPU throttling, including keyboard-sizedresizes and orientation changes. Set `SLATE_TEST_PROFILE_OUTPUT` to save itsJSON measurements. This is a browser stress probe, not a phone latency measurement.Terminal and tmux client resizing waits for 120 ms of stable geometry, thenreports only the final size. Initial terminal sizing remains immediate. Keyboardanimations, orientation changes, and sash drags therefore avoid intermediateremote redraws; tmux may still reflow history for the final size.Touch scrolling sends mouse wheel reports at twice the previous sensitivity fortmux and other mouse-tracking applications. Local scrollback remains 1:1, physicalmouse wheel sensitivity is unchanged, and taps retain the same drag threshold.There is no inertial scrolling or additional queued input after the finger stops.Run `node packages/slate/test/terminal-resize-policy-test.mjs` and`node packages/slate/test/terminal-geometry-browser-gate.mjs` to check resizecoalescing and touch report delivery.Copy/composer synchronization writes only changed DOM state and retains unchangedhistory entries. This prevents the app's mutation observer from turning a controlrefresh into a continuous loop of full terminal redraws.The browser gates accept `SLATE_TEST_WEB_ROOT` for an isolated build. The composergate uses Chrome's mobile viewport and trusted touch/key input with the actualWASM terminal provider; it also exercises IME event ordering. Actual Androidkeyboard visibility and keyboard-specific autocorrect still require phoneverification.docs/predictive-terminal-echo.mdadded
# Predictive terminal echoPredictive echo previews simple typing in the Slate UI while the remoteterminal processes input. The remote terminal remains authoritative. Predictionsare a removable overlay and never change the emulator, command input, or history.It defaults to off. Use **Terminal: Toggle Predictive Echo** in the commandpalette, or set the boolean option `slate/terminal-predictive-echo:` in theTerminal options group. The preference is saved through Slate's normal optionsstore. It takes effect for existing terminals.The first version predicts printable ASCII appended to an otherwise empty endof a single-width row. Two separate confirmed echoes over 150 ms enableprediction for that typing sequence. Two echoes below 80 ms turn it off.Edits, control keys, paste, non-ASCII input, focus changes, resize, scrollback,and a hidden cursor clear predictions and require fresh confirmation.Enter resets learning before the next prompt; no-echo input cannot train thepredictor. The UI does not have the remote process's termios settings, so thisis conservative inference, not a general guarantee about every program's echobehavior.Confirmation checks the complete authoritative cursor row, cursor advancement,and fresh output. Repeated text elsewhere cannot confirm a prediction. Amismatch discards dependent predictions and imposes a one-second backoff.Unconfirmed text becomes dim and underlined after 250 ms and disappears aftertwo seconds. The next authoritative frame always wins.Performance capture automatically clears and suppresses prediction, even whenthe preference is on. For baseline comparisons, leave the option off as well.`window.slatePredictEcho.report()` reports enablement, suppression and counterswithout recording typed text. Predictive paint time is distinct from confirmedterminal latency and must be measured separately.Local checks:```shnode packages/slate/test/terminal-prediction-test.mjsnode packages/slate/test/terminal-prediction-browser-gate.mjs```The browser gate needs a completed web build and Chrome. Set`SLATE_TEST_WEB_ROOT` to test an isolated build. It uses a temporary profile anda controlled terminal provider; it does not contact a phone or measure networkperformance.Tmux's alternate screen, application cursor mode, and mouse tracking no longerexclude echo learning by themselves. The same append-only ASCII and exactcursor-row confirmation rules apply inside tmux. This can help at shell promptsinside tmux; it is not general prediction of arbitrary editor operations orfull-screen redraws. Cursor movement, edits and unconfirmed output still resetlearning.To validate with actual tmux output on a disposable private socket:```shnode packages/slate/test/terminal-tmux-prediction-fixture.mjs /tmp/slate-tmux-prediction.jsonSLATE_TEST_TMUX_FIXTURE=/tmp/slate-tmux-prediction.json node packages/slate/test/terminal-prediction-browser-gate.mjs```docs/terminal-build-baseline.mdadded
# Terminal build baselineThe terminal improvements validated on 2026-09-07 use the official Sigil 0.22.0compiler and its matching runtime packages. The lock retains the existing pinsfor packages outside the Sigil monorepo.`sigil-stdlib` is temporarily pinned to`9fdbd7aff420e65c8c2c73f0f97abf8dbfae2d95`, the scheduler fix immediately after0.22.0. A socket may have independent read and write waiters. Previously,readiness in one direction could discard the waiter in the other direction,leaving a terminal stuck during backpressure/replay. The pinned library removesonly the waiter for the ready direction. This fix is compiled into a node;updating the CLI alone does not change an already-installed node binary.The explicit standard-library dependency comes first in the app manifest soworkspace dependencies cannot preempt it. Its runtime and test peers resolvefrom the same commit; their source is unchanged from 0.22.0.Use the committed dependency lock with `sigil deps install`. No local sourceredirect is required. Replace the temporary standard-library commit pin with areleased version once a Sigil release includes the fix.The staged client version is `2026-09-07-terminal-compose-1`. Its optimized WASMbuild and the actual-browser composer and prediction gates passed. Validationalso includes 27 predictive-echo model checks, 24 gateway/appearance checks, andsix real-socket Unicode HTTP-body cases. See `mobile-terminal-input.md` and`predictive-terminal-echo.md` for behavior and browser-gate commands.Composer history and the frozen terminal copy mode are follow-up work, not partof this checkpoint.docs/terminal-copy.mdadded
# Terminal copyingOpen **Copy** in the accessory bar, then choose **Enter copy mode** to freezethe current screen and retained scrollback in the normal terminal pane. Openingthe menu alone leaves the terminal live. The category appears only for terminals;the keyboard button and key grid keep their positions across view changes.Incoming output continues feeding the live terminal in the background.Scrolling and selecting use the frozen rows,so new output cannot move or erase the selected text, even when the livescrollback buffer rolls over.On a phone, tap the beginning of the desired text, tap **Mark**, scroll, then tapthe other endpoint. The selection extends across screens and works in eitherdirection. The Copy menu contains **Mark**, **Copy selection**, and **Cancel**while selecting. Tap **Copy selection** to put the region on the system clipboardand return to live output, or **Cancel** to return without copying. A failed clipboard writekeeps the frozen selection available for another attempt.Closing the menu keeps the selection frozen. A small **Copy mode** indicatorin the existing terminal header remains visible until copying or canceling.The help text closes with its menu and returns when that menu is reopened.On desktop, compact controls live directly in that header. Neither layout addsa dedicated row above the terminal.Keyboard controls also work with the mobile bar's sticky Ctrl and Alt keys:| Action | Keys || --- | --- || Set mark | Ctrl+Space || Move by character or row | Ctrl+B/F/P/N or arrow keys || Beginning/end of row | Ctrl+A/E or Home/End || Move by word | Alt+B/F || Previous/next page | Alt+V / Ctrl+V or PageUp/PageDown || Beginning/end of retained buffer | Alt+< / Alt+> or Ctrl+Home/End || Copy region and return to live output | Alt+W or Ctrl+Shift+C || Cancel | Ctrl+G or Escape |Other terminal input is consumed locally while copy mode is active. Composersubmission is refused and its draft retained until the terminal is live again.Copy mode does not detect or enter tmux copy mode: it selects the rows Slate hasreceived. For a full-screen application using the alternate screen, only thatscreen is available; unrelated main-screen history is excluded. History heldprivately by tmux must be selected through tmux itself.Rows currently copy with physical line breaks and omit trailing padding spaces.The terminal emulator does not retain the soft-wrap metadata needed to rejoinwrapped logical lines reliably.## Clipboard text from tmuxWhen tmux or another terminal application sends an OSC 52 clipboard sequence,Slate stages the text and badges the terminal's **Copy** category, even whilethe menu is closed. Choose **Copy received text** from the menu, or useCtrl+Shift+Y, to copy it with a browser user gesture. An unsuccessful clipboardwrite keeps the staged text. A delayed acknowledgmentdoes not clear a newer, different clipboard payload.Tmux must be configured to emit clipboard sequences to its terminal. Forexample, enable `set-clipboard on` and advertise clipboard support in theappropriate `terminal-features` entry for the outer terminal. Slate's existingOSC 52 policy still applies; setting it to `off` disables staging.## ValidationBuild the web app, then run:```shnode packages/slate/test/terminal-copy-test.mjsnode packages/slate/test/terminal-copy-browser-gate.mjs```The browser gate accepts `SLATE_TEST_WEB_ROOT` for an isolated build. It checksfive-entry history, recall without sending, frozen scrollback across live-bufferrollover, Emacs and touch selection, input isolation, alternate-screen behavior,clipboard failure recovery, and an actual browser clipboard write.It also checks menu-only entry, fixed keyboard/key-grid positions across editorand terminal views, closing/reopening a frozen selection without leaking helpinto the category row, the received-textbadge, and desktop header controls. Set`SLATE_TEST_OSC52_FILE` to a JSON fixture containing `text` and `sequence` to useclipboard output captured from a real tmux session. Headless Chrome tests donot establish Android keyboard visibility or platform clipboard permissionbehavior; those still need phone dogfooding.docs/terminal-reattach.mdadded
# Reattaching terminalsRun **Terminal: Reattach** from the command palette to list running terminals onthe active node. Each row shows the shell command, session ID, starting directorywhen known, attachment status, and time since activity. Choose a row to resumethat session. If the terminal is already open in this Slate UI, the commandfocuses its existing view.Listing does not attach or close anything. Reattachment never falls back tocreating a fresh shell if the selected session has exited. Attached sessionsare identified so the user can see when selecting one will take over its inputconnection. Underlying tmux sessions continue independently of these wrappers.Updated nodes combine their direct session hub with retained relay holders.Each hub filters by the authenticated principal; session identifiers do not grantaccess. Queries use the node's private control socket directory and boundedreplies. Unavailable or older holders produce an incomplete-list message.Older nodes can list sessions in the current hub, but may omit other retainedterminals; the picker asks for a node update instead of presenting that as acomplete inventory.Remote listing currently requires a connected local companion node to carrythe authenticated request. The older browser-only Enclave RPC path does notexpose this catalog.Directory and command describe how the shell was started, not a live processinspection. Node restarts can invalidate plain PTY sessions. A picker cannotrecover a shell whose controlling process is gone, though work inside tmux mayremain accessible through a new tmux client.Surviving relay holders from a previous node process may appear in discovery,but adopting them into the replacement node's routing table is separate work;such an attachment can still be refused without creating a replacement shell.Checks:```shsigil test slate-cli packages/slate-cli/test/test-terminal-discovery.sgl --no-strictnode packages/slate/test/terminal-reattach-browser-gate.mjs```The browser gate accepts `SLATE_TEST_WEB_ROOT` for an isolated build. It usescontrolled catalog replies and the real picker/provider seam. The native testsexercise Unix-socket discovery, principal filtering, and actual PTY reattachment.package.sglmodified
output-dir: "build/dev" debug?: #t optimize: 0) ;; Optimized bytecode daemon, retaining the development execution backend. (config name: 'node-release output-dir: "build/node-release" backend: 'bytecode debug?: #f optimize: 2) ;; Native release — used to native-compile packages/slate-shell into one ;; `slate-shell` binary with the web build baked into the appended SGLPAK archive. (config output-dir: "build/release" backend: 'native debug?: #f optimize: 2)) optimize: 2) ;; Static ARM64 Linux build used by the mobile companion validation. (config name: 'linux-arm64 output-dir: "build/linux-arm64" toolchain: 'zig target: "aarch64-linux-musl" backend: 'native static?: #t debug?: #f optimize: 2 c-flags: (with-sigil-c-flags '("-Wall" "-O2" "-DNDEBUG")) features: '(release cross)) (config name: 'linux-amd64 output-dir: "build/linux-amd64" toolchain: 'zig target: "x86_64-linux-gnu" backend: 'native static?: #f debug?: #f optimize: 2 c-flags: (with-sigil-c-flags '("-Wall" "-O2" "-DNDEBUG")) features: '(release cross)) ;; Fully static AMD64 Linux build for Guix and other hosts where the ;; conventional glibc-linked artifact cannot rely on host FHS paths. (config name: 'linux-amd64-static output-dir: "build/linux-amd64-static" toolchain: 'zig target: "x86_64-linux-musl" backend: 'native static?: #t debug?: #f optimize: 2 c-flags: (with-sigil-c-flags '("-Wall" "-O2" "-DNDEBUG")) features: '(release cross)) ;; Dynamic ARM64 Android build. Sigil's managed Zig compiles the C objects; ;; the Android data-only sysroot supplies bionic headers and link stubs. (config name: 'android-arm64 output-dir: "build/android-arm64" toolchain: 'android target: "aarch64-linux-android" backend: 'native static?: #f debug?: #f optimize: 2 c-flags: (with-sigil-c-flags '("-Wall" "-O2" "-DNDEBUG")) features: '(release cross android)) (config name: 'windows-amd64 output-dir: "build/windows-amd64" toolchain: 'zig target: "x86_64-windows-gnu" backend: 'native static?: #t debug?: #f optimize: 2 c-flags: (with-sigil-c-flags '("-Wall" "-O2" "-DNDEBUG")) features: '(release cross windows)) (config name: 'macos-amd64 output-dir: "build/macos-amd64" toolchain: 'zig target: "x86_64-macos" backend: 'native static?: #f debug?: #f optimize: 2 c-flags: (with-sigil-c-flags '("-Wall" "-O2" "-DNDEBUG")) features: '(release cross macos)) (config name: 'macos-arm64 output-dir: "build/macos-arm64" toolchain: 'zig target: "aarch64-macos" backend: 'native static?: #f debug?: #f optimize: 2 c-flags: (with-sigil-c-flags '("-Wall" "-O2" "-DNDEBUG")) features: '(release cross macos)) ;; OpenBSD release binaries link dynamically against the host libc; OpenBSD ;; deliberately does not promise static binaries as a portable interface. (config name: 'openbsd-amd64 output-dir: "build/openbsd-amd64" toolchain: 'zig target: "x86_64-openbsd" backend: 'native static?: #f debug?: #f optimize: 2 c-flags: (with-sigil-c-flags '("-Wall" "-O2" "-DNDEBUG" "-D_DEFAULT_SOURCE")) features: '(release cross openbsd)) (config name: 'openbsd-arm64 output-dir: "build/openbsd-arm64" toolchain: 'zig target: "aarch64-openbsd" backend: 'native static?: #f debug?: #f optimize: 2 bundle?: #t c-flags: (with-sigil-c-flags '("-Wall" "-O2" "-DNDEBUG" "-D_DEFAULT_SOURCE")) features: '(release cross openbsd))) default-config: 'dev)packages/slate-cli/design/terminal-lifetime.mdadded
# Terminal lifetime across node restartsPTY and process sessions live in independent `slate node _terminal-owner`processes. Local, paired direct and relay transports delegate their sessionframes over Unix sockets in the node's private `pty/` state directory. Thenetwork process still owns pairing, resource RPCs, watches and agent sessions.After a node restart, discovery validates the owner's IPC version, node keyidentity and unique endpoint. The owner retains the original SID, process,replay buffer, flow control and timestamps. Attach never opens a replacementshell when its owner is missing. Channels are mapped per frontend connection,so sessions in different surviving owners cannot collide on their local u32channel numbers. A fresh owner is started only for an explicit open.The private IPC reuses bounded WebSocket framing without an HTTP upgrade.The directory has mode 0700. Each network open is checked against thatconnection's actual exec, PTY and cwd grants before delegation; the owneradditionally checks the principal on list, attach and every session frame.The private socket is a same-OS-user capability and is not a network endpoint.## Cleanup policy- Detached PTYs retain the existing 72-hour idle timeout, configurable through `SLATE_SESSION_TTL`. Zero disables idle expiry. Process sessions keep their 10-minute timeout. Existing sessions retain the policy set when opened.- Attached sessions are exempt. Detach, input and observed output update the idle clock. Output behind exhausted flow-control credit is not yet observed.- Idle expiry and age use wall time, preserving the existing policy and counting time spent suspended on Android. `created` is the original wall-clock timestamp. List and attach report age and TTL; restarting the network process does not reset them. Signal and empty-owner grace deadlines use monotonic time.- Expiry uses HUP, TERM and KILL with the existing five-second rung graces. Pending cleanup counts against the owner's 64-session limit and prevents an empty owner from exiting before its children are reaped.- An owner with no sessions or pending cleanup exits after ten seconds, even if an idle frontend link remains open. It removes its endpoint. The spawning node waits for exited owners while it remains alive; after a node crash, normal OS orphan reaping applies.- Owner discovery/spawn has an eight-endpoint admission budget, and each owner accepts at most 128 IPC connections. Simultaneous first opens in different transport processes can create additional empty owners; they retire normally. This is not a transactional, cross-process global quota.- Transport workers exit explicitly after their streams and local sessions are gone. Their background reaper must not keep an empty worker alive.Stopping or upgrading the network node leaves owned sessions running underthis policy. Closing a terminal explicitly ends it. Rebooting the OS or killingthe owner ends its sessions; persistence does not restore killed processes.The first upgrade from the previous architecture cannot adopt existing PTYmaster descriptors, so those old terminal connections need to be reopened.## VerificationRun `node packages/slate-cli/test/terminal-restart-gate.cjs /path/to/slate` onLinux with a native candidate. It creates isolated state and disposable PTYs,checks repeated open/close descriptor counts, local/relay reattachment,principal isolation, resource RPCs, normal stops, forced crashes, originalPID/SID/timestamps, autonomous expiry, empty-worker retirement and owner-crashreporting. It does not connect to installed nodes or user tmux sessions.Session/grant regressions are in `test-terminal-owner.sgl`,`test-terminal-discovery.sgl`, `test-reaper.sgl` and `test-sessions.sgl`.Set `SLATE_TEST_BIN` for the reaper's real worker boot check.packages/slate-cli/docs/sigil-http-streaming-integration.mdmodified
The A4 implementation separates three layers:- `(sigil http client)` defines `http-stream-response`: one response-head callback followed by bytevector body callbacks. `(slate-cli http stream)` is now only a compatibility re-export that preserves Slate's import boundary. callback followed by bytevector body callbacks. Slate imports it directly.- `(slate-cli http sse)` is a transport-neutral incremental SSE decoder. It handles arbitrary chunk boundaries, CRLF, comments, multiline data, event names, persistent IDs, and retry values.The transport lives beside `http-fetch-bytes` and reuses sigil-http's connection,deadline, request, header, and framing helpers. It invokes `on-chunk` as eachde-framed body chunk arrives instead of accumulating with `fetch-assemble`.Slate is pinned to sigil-http 0.18.5. It can import `(sigil http client)` directlyand delete `slate-cli/http/stream.sgl` in a later mechanical cleanup; no SSE orprovider code needs to change.Slate is pinned to sigil-http 0.18.5. No SSE or provider code needs to duplicatethe transport implementation.The SSE decoder can still be upstreamed independently as `(sigil http sse)` withthe same three exports.packages/slate-cli/package.sglmodified
;;;;;; Build (from the workspace root):;;; sigil deps install;;; sigil build slate-cli --config release # builds the slate-cli package;;; ./build/release/bin/slate serve # ...which produces the `slate` binary;;; sigil build slate-cli --config node-release # optimized bytecode daemon;;; ./build/node-release/bin/slate serve # produces the `slate` binary;;;;;; See the design notes (folio: topics/slate-distribution-architecture and;;; topics/familiar-local-transport-design) for the architecture, wire(package name: "slate-cli" ;; version inherited from the workspace (../../package.sgl) — member packages ;; do not declare their own version. sigil: "0.20.1" ;; The CLI/node has an independent SemVer release stream. The renderer/app ;; uses Slate CalVer, so this package must not inherit that version. version: "0.1.3" sigil: "0.21" description: "Slate node — local capability daemon + localhost PWA proxy" url: "https://codeberg.org/sigil/slate" license: "BSD-3-Clause" ;; ex-slate-desktop package) vacated the `slate` bundle name to free it here. bundle-name: "slate" ;; NOTE: build configs (`dev` native for tests, `release` native bundle) live ;; NOTE: build configs (`dev` for tests, `node-release` optimized bytecode) live ;; at the WORKSPACE level (../../package.sgl) now that slate-cli is a package ;; in the slate workspace — mirroring slate / slate-shell. Build the daemon ;; with `sigil build slate-cli --config release`; `sigil test slate-cli` uses ;; `dev`. ;; with `sigil build slate-cli --config node-release`; `sigil test slate-cli` ;; uses `dev`. The native `release` config remains available for comparisons ;; and is the desktop shell's build target. dependencies: (list (from-git url: "codeberg:sigil/sigil" package: "sigil-stdlib" version: "^0.20") ;; Keep independent read/write waiters on the same socket until the next Sigil release. (from-git url: "codeberg:sigil/sigil" package: "sigil-stdlib" commit: "9fdbd7aff420e65c8c2c73f0f97abf8dbfae2d95") (from-git url: "codeberg:sigil/sigil-system" version: "^0.1") (from-git url: "codeberg:sigil/sigil-wire" version: "^0.1") (from-git url: "codeberg:sigil/sigil-websocket" version: "^0.10.2") (from-git url: "codeberg:sigil/sigil-websocket" version: "^0.10.13") (from-git url: "codeberg:sigil/sigil-http" version: "^0.18") (from-git url: "codeberg:sigil/sigil-json" version: "^0.16") (from-git url: "codeberg:sigil/sigil-mcp" version: "^0.17") (from-git url: "codeberg:sigil/sigil-crypto" version: "^0.16") (from-git url: "codeberg:sigil/sigil-mcp" version: "^0.17") (from-git url: "codeberg:sigil/sigil-crypto" version: "^0.16.6") (from-git url: "codeberg:sigil/sigil-tls" version: "^0.16.7") ;; Diagnostics. `^0.16` is the range courier and folio both declare, so ;; the three daemons resolve the same logging library and the `--log` / ;; `--log-level` flags mean the same thing everywhere. See ;; topics/sigil-log-cli-integration. (from-git url: "codeberg:sigil/sigil-log" version: "^0.17") (from-git url: "codeberg:sigil/sigil-irc" package: "sigil-irc" version: "^0.16") (from-git url: "codeberg:sigil/sigil-irc" package: "sigil-irc-websocket" version: "^0.16")) (from-git url: "codeberg:sigil/sigil-irc" package: "sigil-irc" version: "^0.16.5") (from-git url: "codeberg:sigil/sigil-irc" package: "sigil-irc-websocket" version: "^0.16.5")) dev-dependencies: (list (from-git url: "codeberg:sigil/sigil" package: "sigil-test" version: "^0.20") (from-git url: "codeberg:sigil/sigil" package: "sigil-test-runner" version: "^0.20"))) ;; Repository release automation is a single Sigil script which uses the ;; structured shell API; it is not linked into the shipped node. (from-git url: "codeberg:sigil/sigil" package: "sigil-shell" version: ">=0.20") (from-git url: "codeberg:sigil/sigil" package: "sigil-test" version: ">=0.20") (from-git url: "codeberg:sigil/sigil" package: "sigil-test-runner" version: ">=0.20")))packages/slate-cli/src/slate-cli/agent/ceiling.sgladded
;;; The agent session's authority ceiling (A1').;;;;;; The node computes `(attenuate node-root child-narrowing)` and hands the;;; child ONLY THE RESULT. The child never receives the node's root as data;;; it narrows itself: that would be the root crossing the boundary, one;;; forged field away from a bearer token. This is ENDOWMENT, a parent;;; creating a child that holds a narrowing of the parent's own authority;;; at creation, and the separating test is that the receiver can obtain no;;; more than the sender chose to give and cannot replay it elsewhere.;;;;;; Before A1' the harness minted a ceiling FRESH from the workspace and the;;; exec flag and never intersected it with anything, so an agent session;;; could hold authority the node itself did not.;;;;;; This lives in its own module rather than inside the hub so the ceiling;;; can be tested as the REAL computation. A test that reimplemented the;;; arithmetic would agree with itself by construction.(define-library (slate-cli agent ceiling) (import (sigil io) (sigil string) (sigil process) (sigil grant)) (export origin-of-principal child-narrowing-specs compute-ceiling ceiling-reduced? grant-args) (begin ;; "web:https://example.org" -> "https://example.org" (define (origin-of-principal p) (if (string-starts-with? p "web:") (substring p 4 (string-length p)) p)) ;; `(sigil grant)` is PURE and refuses a non-absolute fs path, so `~` ;; must be expanded before the parser ever sees it. `realpath` does NOT ;; expand `~` (probed elsewhere in this codebase), which is why the ;; expansion is explicit here rather than delegated. (define (expand-tilde-spec spec) (let ((home (getenv "HOME"))) (if (not home) spec (let ((parts (string-split spec ":"))) (if (and (>= (length parts) 3) (string=? (car parts) "fs")) (let ((path (string-join (cddr parts) ":"))) (string-append "fs:" (cadr parts) ":" (cond ((string=? path "~") home) ((string-starts-with? path "~/") (string-append home (substring path 1 (string-length path)))) (else path)))) spec))))) ;; A spec this build cannot represent is DROPPED, not fatal, and dropped ;; LOUDLY. Silently discarding a configured grant is how an operator ends ;; up debugging a denial with nothing to read; refusing to start over one ;; bad line is worse. Deny-by-default makes the drop the safe direction. (define (specs->grant-value specs label) (let loop ((rest specs) (acc '())) (if (null? rest) (make-grant acc) (let ((f (guard (e (#t (begin (println "agent: ignoring unrepresentable ~a grant ~s: ~a" label (car rest) (error-object-message e)) #f))) (spec->facet (expand-tilde-spec (car rest)))))) (loop (cdr rest) (if f (cons f acc) acc)))))) ;;; What the session is ASKING for: exactly what the pre-A1' harness ;;; minted, now an input to be intersected rather than a ceiling to be ;;; trusted. (define (child-narrowing-specs workspace exec?) (append (list (string-append "fs:rw:" workspace)) (if exec? (list "exec:full") '()))) ;;; The ceiling: the greatest authority covered by BOTH what the node ;;; holds and what the session asked for. ;;; ;;; `attenuate` is a proven meet, so this composes and can never widen: ;;; the result is covered by the node root for every possible input. (define (compute-ceiling node-specs wanted-specs) (grant->specs (attenuate (specs->grant-value node-specs "node") (specs->grant-value wanted-specs "session")))) ;;; Did the session get less than it asked for? Compared by AUTHORITY ;;; rather than by spelling, so a reordering or a redundant facet does ;;; not read as a reduction. (define (ceiling-reduced? node-specs wanted-specs) (not (grant=? (specs->grant-value wanted-specs "session") (specs->grant-value (compute-ceiling node-specs wanted-specs) "ceiling")))) ;;; ONE SPEC PER ARGV ELEMENT, never a packed string. argv already has ;;; list structure, so nothing here has to split on a separator the spec ;;; grammar is allowed to contain. That is the direct answer at this ;;; boundary to t-deb92a one layer up, where a spec LIST packed into one ;;; string is split on a comma `exec:allowlist:` may itself contain and ;;; the malformed remainder is silently dropped. (define (grant-args specs) (let loop ((rest specs) (acc '())) (if (null? rest) (reverse acc) (loop (cdr rest) (cons (car rest) (cons "--grant" acc))))))))packages/slate-cli/src/slate-cli/agent/host.sglmodified
response)))) (define (usage!) (display "usage: slate agent-host SESSION-DIR WORKSPACE PROVIDER MODEL [--exec]\n") (display "usage: slate agent-host SESSION-DIR WORKSPACE PROVIDER MODEL [--exec] [--grant SPEC]...\n") (exit 2)) ;; Collect every `--grant SPEC` pair. ONE SPEC PER ARGUMENT, never a ;; packed string: argv already has list structure, so nothing here has ;; to split on a separator the spec grammar is allowed to contain. ;; ;; These specs ARE this session's authority. The node computed them as ;; `(attenuate node-root child-narrowing)` and handed down only the ;; result, so this process holds a ceiling it cannot widen and never ;; sees the node's root at all. (define (parse-grant-specs args) (let loop ((rest args) (acc '())) (cond ((null? rest) (reverse acc)) ((and (string=? (car rest) "--grant") (pair? (cdr rest))) (loop (cddr rest) (cons (cadr rest) acc))) (else (loop (cdr rest) acc))))) (define (run-agent-host! args) (when (< (length args) 4) (usage!)) (let* ((session-dir (car args)) (let ((tail (member "--reasoning-effort" args))) (if (and tail (pair? (cdr tail))) (cadr tail) #f))) (exec? (and (member "--exec" args) #t)) (grant-specs (parse-grant-specs args)) (state-dir (config-state-dir (make-config))) (broker (make-credential-broker state-dir)) (config (credential-broker-migrate-config! broker)) session-key: session-dir)) openai-complete) sink timeout: 120 exec?: turn-exec? grant-specs: grant-specs cancel-state: cancel-state delivery-id: delivery-id approval-wait: approval-waitpackages/slate-cli/src/slate-cli/agent/hub.sglmodified
(slate-cli agent config) (slate-cli agent credential-broker) (slate-cli agent provider) (slate-cli agent ceiling) (slate-cli agent policy) (slate-cli agent permissions) (slate-cli agent jobs) #{ error: #t message: "Child host exited before starting its assignment" })) (reconcile-child! hub (vector-ref hub 9) session))) ;; ================================================================ ;; The child's authority ceiling (A1') ;; ;; The node computes `(attenuate node-root child-narrowing)` and hands ;; the child ONLY THE RESULT. The child never receives the node's root ;; as data it narrows itself: that would be the root crossing the ;; boundary, one forged field away from a bearer token. This is ;; ENDOWMENT (a parent creating a child holding a narrowing of its own ;; authority, at creation), not transmission, and the separating test ;; is that the receiver can obtain no more than the sender chose to ;; give and cannot replay it against a third party. ;; ;; Before this, `make-agent-grants` minted a ceiling FRESH from the ;; workspace and the exec flag and never intersected it with anything. ;; A session could therefore hold authority the node itself did not. ;; ================================================================ ;; The ceiling arithmetic lives in (slate-cli agent ceiling) so it can be ;; tested as the REAL computation rather than through a mirror of itself. ;; What stays here is the composition and the reporting: which grants this ;; node holds for this session's principal, and saying so when the session ;; gets less than it asked for. (define (child-ceiling-specs session config) (let* ((origin (origin-of-principal (as-principal session))) (node-specs (grants-for-origin config origin)) (session-config (transcript-session-config (as-dir session))) (read-only? (and (dict? session-config) (dict-ref session-config read-only: #f))) (wanted-specs (append (list (string-append (if read-only? "fs:ro:" "fs:rw:") (as-workspace session))) (if (and (as-exec? session) (not read-only?)) (list "exec:full") '()))) (granted (compute-ceiling node-specs wanted-specs))) ;; THE REDUCTION IS RECORDED WHENEVER IT HAPPENS. Whether any ;; deployment relies on the previously un-intersected ceiling is a ;; question about how machines are configured, which the source ;; cannot answer, so a session that gets less than it asked for says ;; so at the moment it is created. An operator whose agent cannot ;; write where it expects then has a line to read instead of a ;; mystery. (when (ceiling-reduced? node-specs wanted-specs) (println "agent: ceiling reduced for ~a: asked ~s, node holds ~s, granted ~s" (as-principal session) wanted-specs node-specs granted)) (when (null? granted) (println "agent: ceiling is EMPTY for ~a: the node holds nothing overlapping ~s" (as-principal session) wanted-specs)) granted)) (define (spawn-sidecar! hub session config) (let* ((session-config (transcript-session-config (as-dir session))) (effort (if (transcript-error? session-config) "" (if (as-exec? session) (list "--exec") '()) (if (> (string-length effort) 0) (list "--reasoning-effort" effort) '()) (grant-args (child-ceiling-specs session config)) (list die-with-parent: #t))) (proc (guard (e (#t #f)) (apply process-spawn args)))) (when proc grants: #{ exec: exec? } persona: persona drafts: '() policy: "auto-workspace" policy-version: policy-version reasoning-effort: effort ;; A RECORD OF WHAT WAS GRANTED AT OPEN, NOT A ;; SOURCE OF AUTHORITY. Every spawn recomputes ;; the ceiling from the session and the CURRENT ;; config, so a restart picks up a changed node ;; posture rather than replaying a stale one: ;; grants are resolved at the consumption site, ;; never read back from a stored record. ;; ;; It is here because the compatibility question ;; this change raises cannot be answered from the ;; source. An operator whose session cannot write ;; where it expects needs something durable and ;; per-session to read, not a log line they had to ;; be watching for. ceiling: (list->array (child-ceiling-specs session config)) limits: limits }))) (if (transcript-error? created) (reply-err conn id "open-failed" "agent transcript could not be created") (if (and target (valid-address? args)) (let ((result (update-session-info! target args (principal-of conn) #t))) (if (dict-ref result ok: #f) (begin (reply-ok conn id (dict-ref result result: #{})) (publish-fleet! hub target)) (begin ;; Grant argv belongs to the host lifetime. Changing ;; execution permission requires a freshly endowed host; ;; the transcript and provider session key remain durable. (when (and (dict-contains? args exec:) (as-proc target)) (let ((proc (as-proc target))) (stop-child-host! proc) (let wait () (when (eq? proc (as-proc target)) (sleep 0.01) (wait))))) (reply-ok conn id (dict-ref result result: #{})) (publish-fleet! hub target)) (let ((err (dict-ref result error: #{}))) (reply-err conn id (dict-ref err kind: "session-settings") (dict-ref err message: "Could not update session settings")))))packages/slate-cli/src/slate-cli/agent/loop.sglmodified
(loop (cdr rest) (append context (list message)) #f))))))))) ;; complete is the adapter api-family function and has A0's signature. ;; `grant-specs:` is the ceiling the NODE computed and handed down. When ;; it is supplied it IS the session's authority, replacing the ceiling ;; this process used to mint for itself out of the workspace and the ;; exec flag. That old ceiling was never intersected with the node's own ;; grants, so a session could hold authority the node did not. ;; ;; When it is absent the session gets `make-agent-grants`, which is the ;; pre-A1' behaviour and is what the in-process tests exercise. Every ;; production path goes through the sidecar and therefore supplies it. (define (run-agent-turn! session-dir workspace provider model text deliver-as actor complete sink (keys: (timeout 30) (exec? #f) (cancel-state #f) (keys: (timeout 30) (exec? #f) (grant-specs #f) (cancel-state #f) (settle? (lambda () #t)) (delivery-id #f) (approval-wait #f) (exec-runner #f) (child-runner #f) (source-kind "controller") (user (dict-set (dict-set (make-user-message effective-text) actor: actor) source-kind: source-kind)) (grants (make-agent-grants workspace exec? (dict-ref safe-session-config read-only: #f))) (grants (if grant-specs (grants-from-specs grant-specs) (make-agent-grants workspace exec? (dict-ref safe-session-config read-only: #f)))) (resource-items (filter valid-resource-attachment? context-items)) (projection (checkpoint-projection session-dirpackages/slate-cli/src/slate-cli/agent/openai-completions.sglmodified
(sigil io) (sigil array) (sigil json) (slate-cli http stream) (sigil http client) (slate-cli http sse) (slate-cli agent provider))packages/slate-cli/src/slate-cli/agent/openai/oauth.sglmodified
;;; Node-owned OpenAI Codex OAuth transaction primitives.(define-library (slate-cli agent openai oauth) (import (sigil core) (sigil math) (sigil string) (sigil crypto) (sigil json) (slate-cli http stream) (slate-cli http sse) (sigil http client) (slate-cli http sse) (slate-cli agent provider) (slate-cli agent credential-broker)) (export make-codex-oauth-transaction codex-oauth-state codex-oauth-verifier codex-oauth-challenge codex-oauth-public-statepackages/slate-cli/src/slate-cli/agent/openai/responses.sglmodified
(define-library (slate-cli agent openai responses) (import (sigil core) (sigil string) (sigil json) (sigil array) (sigil crypto) (sigil io) (slate-cli http stream) (slate-cli http sse) (sigil http client) (slate-cli http sse) (slate-cli agent provider)) (export responses-request responses-message->input make-responses-collector responses-stream-feed! responses-stream-resultpackages/slate-cli/src/slate-cli/agent/service.sglmodified
;;; One node-owned agent hub, independent of browser and Enclave relay lifetimes.(define-library (slate-cli agent service) (import (sigil fs) (sigil path) (sigil io) (sigil process) (import (sigil fs) (sigil string) (sigil path) (sigil io) (sigil process) (sigil socket) (sigil async) (sigil channels) (slate-cli config) (slate-cli listen) (slate-cli agent hub) (slate-cli agent ipc) ;; Private entry point, launched while flock holds the state-directory lock. (define (run-agent-service! args) (unless (= (length args) 1) (error "Agent service requires its state directory")) (let* ((config (make-config state-dir: (car args))) (let* (;; The private service represents the OS node owner. Browser grants ;; are checked by the adapter before it can enter this surface. (configured-grants (getenv "SLATE_NODE_GRANTS")) (owner-grants (if (and configured-grants (> (string-length configured-grants) 0)) (string-split configured-grants ",") owner-silent-pair-grants)) (config (make-config state-dir: (car args) grants: (list (cons "node-owner" owner-grants)))) (dir (agent-service-prepare! config))) (with-async (let* ((hub (make-agent-hub))packages/slate-cli/src/slate-cli/agent/tools.sglmodified
;;; A1 agent tools. All execution enters through dispatch-agent-tool.(define-library (slate-cli agent tools) (import (sigil fs) (import (sigil io) (sigil fs) (sigil math) (sigil path) (sigil string) (export agent-tool-definitions make-agent-grants agent-action-ceiling-ok? grants-from-specs dispatch-agent-tool) (begin (when exec? (grant-add! g "exec:full")) g)) ;; Build the session's grants table from the ceiling the node computed ;; and handed down, one spec per element. ;; ;; `grant-add!` is `(sigil system grant)`'s own entry point, so path ;; canonicalisation and the symlink-escape handling that goes with it ;; stay where they belong. A1' deliberately does NOT reimplement that: ;; the escape is real, and a sabotage arm on the sigil-system migration ;; proved it by removing `realpath` from the check site and watching an ;; escaping symlink flip from denied to permitted. ;; ;; An EMPTY spec list yields a table that permits nothing, which is the ;; correct reading: a facet is a permission and absence is denial. A ;; node holding nothing that overlaps what the session asked for should ;; produce a session that can do nothing, not one that falls back to ;; minting its own ceiling. (define (grants-from-specs specs) (let ((g (make-grants principal: "agent-session"))) (for-each (lambda (spec) ;; A spec this build cannot represent is dropped LOUDLY rather ;; than silently: a swallowed grant is how an operator ends up ;; debugging a denial with nothing to read. Dropping is the safe ;; direction under deny-by-default. (guard (e (#t (println "agent: ignoring unusable grant ~s: ~a" spec (error-object-message e)))) (grant-add! g spec))) specs) g)) (define (absolute-target workspace path) (if (and (> (string-length path) 0) (char=? (string-ref path 0) #\/))packages/slate-cli/src/slate-cli/config.sglmodified
config-port config-dev-mode? config-machine-name local-node-name-path valid-local-node-name? local-node-name local-node-save-name! local-node-ensure-name! config-state-dir discover-state-dir discover-config-dir (cache-dir #f) (session-ttl #f) (agent-host-command #f))) (let* ((resolved-state-dir (or state-dir (discover-state-dir))) (resolved-machine-name (or machine-name (local-node-name resolved-state-dir)))) #{ port: port dev-mode?: dev-mode? machine-name: (or machine-name (discover-machine-name)) state-dir: (or state-dir (discover-state-dir)) machine-name: resolved-machine-name state-dir: resolved-state-dir promptable-origins: (or promptable-origins default-promptable-origins) grants: (or grants default-grants) app-upstream: (or app-upstream default-app-upstream) ;; Test seam for exercising the native hub against a controlled ;; sidecar. Production always falls back to this executable's ;; `agent-host` subcommand. agent-host-command: agent-host-command }) agent-host-command: agent-host-command })) (define (config-port c) (dict-ref c port: 32640)) (define (config-dev-mode? c) (dict-ref c dev-mode?: #f)) (if (string? s) (string-trim s) "this computer"))) (else "this computer")))) ;; The local root has an identity before it has Enclave credentials. ;; Keep that identity in its own small durable record: enrollment may be ;; absent, offline, revoked, or recreated without changing addresses that ;; name this node. A missing record deliberately falls back to hostname ;; so foreground/zero-install use remains zero configuration. (define (local-node-name-path state-dir) (path-join state-dir "node-name")) ;; Mirror Enclave's valid-bot-nick? grammar so a name accepted locally can ;; later be requested unchanged during enrollment. Length is intentionally ;; not capped: Enclave accepts long names and only truncates UI labels. (define (valid-local-node-name? name) (and (string? name) (> (string-length name) 0) (let ((first (string-ref name 0))) (and (not (char=? first #\#)) (not (char=? first #\:)) (not (char=? first #\@)))) (let scan ((i 0)) (if (= i (string-length name)) #t (let* ((ch (string-ref name i)) (code (char->integer ch))) (and (>= code 32) (not (= code 127)) (not (member ch (list #\space #\, #\! #\@ #\:))) (scan (+ i 1)))))))) (define (local-node-name state-dir) (let* ((path (local-node-name-path state-dir)) (stored (and (file-exists? path) (guard (e (#t #f)) (string-trim (read-file-string path)))))) (cond ((and stored (valid-local-node-name? stored)) stored) ((file-exists? path) ;; Identity corruption is not permission to invent a new root. ;; Refuse startup/install so the operator can repair it explicitly. (error "invalid stored Slate node name" path)) (else (let ((hostname (discover-machine-name))) (if (valid-local-node-name? hostname) hostname "slate-node")))))) (define (local-node-save-name! state-dir name) (unless (valid-local-node-name? name) (error "invalid Slate node name" name)) (ensure-directory state-dir) (let* ((path (local-node-name-path state-dir)) (tmp (string-append path ".tmp"))) (write-file-string tmp (string-append name "\n")) (unless (= (process-run "chmod" "600" tmp) 0) (error "could not secure Slate node name" tmp)) (rename-file tmp path) path)) (define (local-node-ensure-name! state-dir requested) (let ((name (or requested (local-node-name state-dir)))) (unless (valid-local-node-name? name) (error "invalid Slate node name" name)) (unless (and (not requested) (file-exists? (local-node-name-path state-dir))) (local-node-save-name! state-dir name)) name)) ))packages/slate-cli/src/slate-cli/dispatch.sglmodified
(sigil wire) ; wire-encode / wire-decode / make-wire-caps (sigil log) ; structured operation logging (t-b24590) (slate-cli config) (slate-cli logging)) ; next-op-id! / op-label / op-clock (slate-cli git diff) (slate-cli git log) (slate-cli git branch) (slate-cli git mutation) (slate-cli git status) (slate-cli logging) ; next-op-id! / op-label / op-clock (slate-cli node pairing-management) (slate-cli node-posture)) (export ;; The verb dispatcher (unary verbs -> reply descriptor). reply-bulk reply-bulk-id reply-pending-write reply-transfer reply-subscription reply-cancel ;; Reply constructors (exported for tests / the bulk-write path). #{ control: #f bulk: #f bulk-id: 0 pending-write: #{ id: id path: path } }) (define (reply-transfer-open id direction path size) #{ control: #f bulk: #f bulk-id: 0 pending-write: #f transfer: #{ id: id direction: direction path: path size: size } }) ;; A subscription (fs/watch): the descriptor carries the opened ;; watcher handle + path for the front-end to attach to a poll ;; goroutine, plus the `ok` control. `sub` is the client's req id. (define (reply-bulk r) (dict-ref r bulk: #f)) (define (reply-bulk-id r) (dict-ref r bulk-id: 0)) (define (reply-pending-write r) (dict-ref r pending-write: #f)) (define (reply-transfer r) (dict-ref r transfer: #f)) (define (reply-subscription r) (dict-ref r subscription: #f)) (define (reply-cancel r) (dict-ref r cancel: #f)) req-id: id verb: verb path: (args-path-summary args) code: (reply-code r) detail: (safe-message e) detail: (reply-message r) duration-ms: (op-elapsed-ms t0)) r))) (let ((r (dispatch-verb daemon origin grants id verb args))) duration-ms: (op-elapsed-ms t0)))))) (define (dispatch-verb daemon origin grants id verb args) (cond ((string=? verb "fs/read-dir") (verb-read-dir grants id args)) ((string=? verb "fs/stat") (verb-stat grants id args)) ((string=? verb "fs/read-file") (verb-read-file grants id args)) ((string=? verb "fs/write-file") (verb-write-file grants id args)) ((string=? verb "fs/mkdir") (verb-mkdir grants id args)) ((string=? verb "fs/rename") (verb-rename grants id args)) ((string=? verb "fs/delete") (verb-delete grants id args)) ((string=? verb "proc/run") (verb-proc-run grants id args)) ((string=? verb "sys/info") (verb-sys-info daemon origin grants id)) ;; fs/watch + fs/unwatch are subscriptions: the capability core ;; (grant check + watcher) lives here so the verb table stays ;; whole; the front-end reads the subscription/cancel descriptor ;; and owns only the transport-specific poll goroutine + ev emit. ((string=? verb "fs/watch") (verb-watch grants id args)) ((string=? verb "fs/unwatch") (verb-unwatch id args)) (else (reply-err id "unknown-verb" (string-append "unknown verb: " verb))))) (if (not (local-node-allows-verb? verb)) (reply-err id "role-denied" (string-append "local-node role does not allow verb: " verb)) (cond ((string=? verb "fs/read-dir") (verb-read-dir grants id args)) ((string=? verb "fs/home") (verb-fs-home grants id)) ((string=? verb "fs/stat") (verb-stat grants id args)) ((string=? verb "fs/read-file") (verb-read-file grants id args)) ((string=? verb "fs/write-file") (verb-write-file grants id args)) ((string=? verb "fs/mkdir") (verb-mkdir grants id args)) ((string=? verb "fs/rename") (verb-rename grants id args)) ((string=? verb "fs/delete") (verb-delete grants id args)) ((string=? verb "proc/run") (verb-proc-run grants id args)) ((string=? verb "git/discover") (reply-inline id (git-discover grants args))) ((string=? verb "git/status") (reply-inline id (git-status grants args))) ((string=? verb "git/branches") (reply-inline id (git-branches grants args))) ((string=? verb "git/switch-branch") (reply-inline id (git-switch-branch grants args))) ((string=? verb "git/create-branch") (reply-inline id (git-create-branch grants args))) ((string=? verb "git/log") (reply-inline id (git-log grants args))) ((string=? verb "git/show") (reply-inline id (git-show grants args))) ((string=? verb "git/diff") (reply-inline id (git-diff grants args))) ((string=? verb "git/stage-file") (reply-inline id (git-stage-file grants args))) ((string=? verb "git/unstage-file") (reply-inline id (git-unstage-file grants args))) ((string=? verb "git/stage-hunk") (reply-inline id (git-stage-hunk grants args))) ((string=? verb "git/unstage-hunk") (reply-inline id (git-unstage-hunk grants args))) ((string=? verb "git/discard-file") (reply-inline id (git-discard-file grants args))) ((string=? verb "git/discard-hunk") (reply-inline id (git-discard-hunk grants args))) ((string=? verb "sys/info") (verb-sys-info daemon origin grants id)) ;; fs/watch + fs/unwatch are subscriptions: the capability core ;; (grant check + watcher) lives here so the verb table stays ;; whole; the front-end reads the subscription/cancel descriptor ;; and owns only the transport-specific poll goroutine + ev emit. ((string=? verb "fs/watch") (verb-watch grants id args)) ((string=? verb "fs/unwatch") (verb-unwatch id args)) (else (reply-err id "unknown-verb" (string-append "unknown verb: " verb)))))) ;; ---- fs/read-dir -> bulk listing ------------------------------- (define (verb-read-dir grants id args) (let ((entries (list->array (fs-read-dir grants (arg-path args))))) (reply-bulk-resp id (wire-encode (array-map stat->wire entries))))) ;; ---- fs/home -> authenticated, grant-checked landing directory -- ;; A client entering a machine needs an absolute starting point; falling ;; back to `/` is unfriendly on Unix and invalid on Android/Termux. Validate ;; HOME through the same filesystem grant engine as every later operation, ;; so this does not turn the authenticated metadata lane into a path leak. (define (verb-fs-home grants id) (let ((home (getenv "HOME"))) (unless (and (string? home) (> (string-length home) 0)) (error "home-unavailable" "the node has no HOME directory")) (fs-stat grants home) (reply-inline id #{ path: home }))) ;; ---- fs/stat -> inline ----------------------------------------- (define (verb-stat grants id args) (reply-inline id (stat->wire (fs-stat grants (arg-path args))))) ;; ---- fs/read-file -> bulk file body (bytes) -------------------- (define (verb-read-file grants id args) (reply-bulk-resp id (wire-encode (fs-read-file-bytes grants (arg-path args))))) (let ((path (arg-path args))) (if (eq? (dict-ref args transfer: #f) #t) (let* ((stat (fs-stat grants path)) (size (dict-ref stat size: -1))) (unless (and (integer? size) (>= size 0) (<= size 4294967295)) (error "transfer-too-large" "file exceeds the v1 transfer boundary")) (reply-transfer-open id 'download path size)) (reply-bulk-resp id (wire-encode (fs-read-file-bytes grants path)))))) ;; ---- fs/write-file: inline (string content) or deferred bulk --- ;; Small text content rides the JSON args. A binary body is uploaded ;; frame arrives. P2 is unary bulk only — one req, one body frame. (define (verb-write-file grants id args) (cond ((eq? (dict-ref args transfer: #f) #t) (let ((path (arg-path args)) (size (dict-ref args size: #f))) (unless (and (integer? size) (>= size 0) (<= size 4294967295)) (error "invalid-args" "streamed fs/write-file needs a u32 size")) (grant-assert! grants 'fs-write path) (reply-transfer-open id 'upload path size))) ((eq? (dict-ref args bulk: #f) #t) (reply-defer-write id (arg-path args))) (else ;; own capabilities/grants can't sensibly require one of those grants ;; to read. The grant SET shown is the origin's configured spec list. (define (verb-sys-info daemon origin grants id) (let ((config (dict-ref daemon config: #f))) (let* ((config (dict-ref daemon config: #f)) (direct-cell (dict-ref daemon direct: #f)) (identity-cell (dict-ref daemon node-identity: #f)) (node-identity (if (and (vector? identity-cell) (> (vector-length identity-cell) 0)) (vector-ref identity-cell 0) "")) (label-cell (dict-ref daemon node-label: #f)) (node-label (if (and (vector? label-cell) (> (vector-length label-cell) 0)) (vector-ref label-cell 0) "")) (direct (and (vector? direct-cell) (> (vector-length direct-cell) 0) (vector-ref direct-cell 0))) (advertised-at (and direct (vector-ref (dict-ref direct advertised-cell: #()) 0)))) (reply-inline id #{ hostname: (if config (config-machine-name config) "this computer") platform: (host-os-string) arch: (host-arch-string) instance: (dict-ref daemon instance: "") node-identity: node-identity node-label: node-label principal: (grants-principal grants) capabilities: #[ "fs" "proc" "sys" ] ; session/pty arrives P3 capabilities: (local-node-capability-names) tmux: (tmux-capability grants) direct: (if direct #{ available: #t address: (dict-ref direct host: "") port: (dict-ref direct port: 0) endpoint: (dict-ref direct endpoint: "") advertisement-expires-at: (if (> advertised-at 0) (+ advertised-at 60) 0) } #{ available: #f address: "" port: 0 endpoint: "" advertisement-expires-at: 0 }) pairings: (guard (e (#t '())) (and config (pairing-management-list (config-state-dir config)))) pairing-revocation-freshness: (if (vector-ref (dict-ref daemon enclave-control-ready: (vector #f)) 0) "current" "stale") grants: (if config (grants-for-origin config origin) '()) }))) ;; ---------------------------------------------------------------- (let ((b (reply-bulk r))) (if (bytevector? b) (bytevector-length b) 0)))) ;;; The path(s) a request names, for the log line. ;;; ;;; PATHS ARE LOGGED IN FULL AND UNTRUNCATED. That is a decision, not an ;;; oversight — recorded in the task note so it can be revisited. The ;;; symptoms this logging exists to diagnose are "what happened to THIS ;;; file", and a truncated or elided path cannot answer that. The cost is ;;; that a debug-level node log contains the names of every file touched, ;;; which is why debug is not the default level. ;;; ;;; `fs/rename` carries `from:`/`to:` instead of `path:`, and reporting ;;; only one of them would misreport the operation. ;;; Return a bounded path summary for the operation log. Git status names ;;; its worktree inside `repo`, while discovery and filesystem operations ;;; use `path`. Rename carries `from`/`to`. We never inspect or log status ;;; output, patch text, file contents, or Git stderr here. (define (bounded-path-summary value) (if (and (string? value) (> (string-length value) 512)) (string-append (substring value 0 509) "...") value)) (define (args-path-summary args) (guard (e (#t "<unreadable>")) (let ((p (dict-ref args path: #f)) (from (dict-ref args from: #f)) (to (dict-ref args to: #f))) (to (dict-ref args to: #f)) (repo (dict-ref args repo: #f))) (cond ((string? p) p) ((and (string? from) (string? to)) (string-append from " -> " to)) ((string? from) from) ((string? p) (bounded-path-summary p)) ((and (string? from) (string? to)) (bounded-path-summary (string-append from " -> " to))) ((string? from) (bounded-path-summary from)) ((dict? repo) (bounded-path-summary (dict-ref repo worktree: ""))) (else ""))))) ;;; Render a value that is NOT known to be well-formed. Used for the ;; Curated client-error slugs (raised as the error message): ;; surface the slug as the code and the string irritant as the ;; human message. Anything else is a generic "error". ((or (string=? msg "invalid-args") (string=? msg "invalid-bulk")) ((or (string=? msg "invalid-args") (string=? msg "invalid-bulk") (string=? msg "not-a-repository") (string=? msg "git-unavailable") (string=? msg "stale-snapshot") (string=? msg "conflict") (string=? msg "operation-failed")) (reply-err id msg (or (first-irritant e) msg))) (else (reply-err id "error" msg)))))packages/slate-cli/src/slate-cli/enclave-node-init.sglmodified
(sigil json) (sigil irc message)) (export node-init-normalize-origin node-init-websocket-url node-init-stream-websocket-url node-init-request-command node-init-pending-result node-init-complete-result node-init-failure node-init-approval-url node-init-record (string-append "wss://" (substring value 8 (string-length value)) "/ws")) (else (string-append "ws://" (substring value 7 (string-length value)) "/ws"))))) ;; Control and relay streams normally share one endpoint. An explicit ;; stream ingress remains optional so split-plane deployments (and their ;; carrier-only fault gates) do not have to perturb the account socket. (define (node-init-stream-websocket-url record) (dict-ref record stream-websocket-url: (dict-ref record websocket-url: ""))) (define (node-init-request-command account node-name) (make-irc-command "PAIR-REQUEST" account node-name)) (define (node-init-complete-result msg) (let ((status (tag msg "+enclave/result.status")) (node-name (tag msg "+enclave/result.node-name")) (transport-nick (tag msg "+enclave/result.transport-nick")) (bot-id (tag msg "+enclave/result.bot-id")) (token (tag msg "+enclave/result.token"))) (and (pair-request? msg) (equal? status "ok") node-name bot-id token (= (string-length token) 64) #{ node-name: node-name bot-id: bot-id token: token }))) #{ node-name: node-name transport-nick: (or transport-nick node-name) bot-id: bot-id token: token }))) (define (node-init-failure msg) (and (irc-message? msg) (eq? (irc-message-command msg) 'FAIL) "/" (dict-ref pending approval-fragment: ""))) (define (node-init-record origin completed) #{ version: 1 origin: (node-init-normalize-origin origin) #{ version: 2 origin: (node-init-normalize-origin origin) websocket-url: (node-init-websocket-url origin) node-name: (dict-ref completed node-name: "") transport-nick: (dict-ref completed transport-nick: (dict-ref completed node-name: "")) bot-id: (dict-ref completed bot-id: "") token: (dict-ref completed token: "") }) (define (node-init-record<-json text) (guard (e (#t #f)) (let ((record (json-decode text))) (and (= (dict-ref record version: 0) 1) (and (member (dict-ref record version: 0) '(1 2)) (> (string-length (dict-ref record origin: "")) 0) (> (string-length (dict-ref record node-name: "")) 0) (> (string-length (dict-ref record transport-nick: (dict-ref record node-name: ""))) 0) (= (string-length (dict-ref record token: "")) 64) (let ((stream-url (dict-ref record stream-websocket-url: #f))) (or (not stream-url) (and (string? stream-url) (> (string-length stream-url) 0)))) record)))) ;; Never silently accept ambient umask permissions for credentials. Thepackages/slate-cli/src/slate-cli/enclave-node-session.sglmodified
node-session-format-trailing-command node-session-parse-wire-message node-session-phase node-session-active? node-session-done? node-session-reconnect! node-session-set-transport! node-session-reset-transport!) (begin (define *ws* #f) (define (ticket-fields raw) (let ((command-pos (string-find raw "STREAM-TICKET "))) (and command-pos (let* ((tail (substring raw (+ command-pos (string-length "STREAM-TICKET ")) (string-length raw))) (f0 (split-ticket-field tail))) (and f0 (let ((f1 (split-ticket-field (cdr f0)))) (and f1 (let ((f2 (split-ticket-field (cdr f1)))) (and f2 (let ((f3 (split-ticket-field (cdr f2)))) (and f3 (list (car f0) (car f1) (car f2) (car f3) (cdr f3))))))))))))) (let loop ((tail (substring raw (+ command-pos (string-length "STREAM-TICKET ")) (string-length raw))) (fields '())) (let ((field (split-ticket-field tail))) (if field (loop (cdr field) (cons (car field) fields)) (let ((all (reverse (cons tail fields)))) (and (member (length all) '(5 13)) all)))))))) (define (direct-ticket-fields raw) (let ((command-pos (string-find raw "DIRECT-TICKET "))) (and command-pos (let loop ((tail (substring raw (+ command-pos (string-length "DIRECT-TICKET ")) (string-length raw))) (fields '())) (let ((field (split-ticket-field tail))) (if field (loop (cdr field) (cons (car field) fields)) (let ((all (reverse (cons tail fields)))) (and (member (length all) '(7 15)) all)))))))) (define (stream-open-fields raw) (define (enclave-rpc-fields raw expected-verb) (let ((command-pos (string-find raw "ENCLAVE-RPC "))) (and command-pos (let* ((tail (substring raw (let ((f1 (split-ticket-field (cdr f0)))) (and f1 (let ((f2 (split-ticket-field (cdr f1)))) (and f2 (string=? (car f2) "stream/open") (list (car f0) (car f1) (car f2) (cdr f2))))))))))) (and f2 (string=? (car f2) expected-verb) (let ((payload (cdr f2))) (list (car f0) (car f1) (car f2) (if (and (> (string-length payload) 0) (char=? (string-ref payload 0) #\:)) (substring payload 1 (string-length payload)) payload)))))))))))) ;; STREAM-TICKET carries two opaque capabilities and can exceed the native ;; 0.18.x IRC parser's recursive scan limit. Locate its fixed five fields ;; 0.18.x IRC parser's recursive scan limit. Locate its legacy five fields ;; or the node-to-node shape with eight authoritative public descriptors ;; with the native substring search primitive; never interpret capability ;; bytes as either IRC or Sigil syntax. Ordinary lines use the IRC parser. (define (node-session-parse-wire-message raw) (cond ((string-contains? raw "DIRECT-TICKET") (let ((fields (direct-ticket-fields raw))) (if fields (cons 'direct-ticket-message fields) (parse-irc-message raw)))) ((string-contains? raw "STREAM-TICKET") (let ((fields (ticket-fields raw))) (if fields (parse-irc-message raw)))) ((and (string-contains? raw "ENCLAVE-RPC") (string-contains? raw "stream/open")) (let ((fields (stream-open-fields raw))) (let ((fields (enclave-rpc-fields raw "stream/open"))) (if fields (cons 'stream-open-message fields) (parse-irc-message raw)))) ((and (string-contains? raw "ENCLAVE-RPC") (string-contains? raw "stream/ticket")) (let ((fields (enclave-rpc-fields raw "stream/ticket"))) (if fields (cons 'relay-ticket-message fields) (parse-irc-message raw)))) (else (parse-irc-message raw)))) (define (node-session-receive session) (define (node-session-receive-with receiver session) (let ((queued (pop-wire-message!))) (if queued queued (let ((websocket (irc-ws-session-websocket session))) (if (not websocket) 'closed (let ((message (ws-receive websocket))) (let ((message (receiver websocket))) (cond ((and (ws-message? message) (eq? (ws-message-type message) 'text)) ((eq? message 'closed) 'closed) (else message)))))))) (define (node-session-receive-nowait session) (node-session-receive-with ws-receive-nowait session)) (define (default-transport) #{ connect: (lambda (url nick) (irc-ws-connect url: url nick: nick user: nick realname: "Slate node")) (irc-ws-connect url: url nick: nick user: nick realname: "Slate node" connect-timeout-ms: 5000 handshake-timeout-ms: 5000 upgrade-timeout-ms: 5000)) connected?: irc-ws-connected? send: irc-ws-send command: irc-ws-command register: irc-ws-register receive: (lambda (session) (if (string? line) (node-session-parse-wire-message line) line))) receive-nowait: node-session-receive-nowait close: irc-ws-close }) (define *transport* (default-transport)) (define (call key . args) (apply (dict-ref *transport* key) args)) (define (open! phase nick) (set! *phase* phase) (log-info* "enclave connecting" mod: "enclave" phase: phase nick: nick url: (node-init-websocket-url *origin*)) (set! *ws* (call connect: (node-init-websocket-url *origin*) nick)) (let ((started-at (current-milliseconds))) (log-info* "enclave connecting" mod: "enclave" phase: phase nick: nick url: (node-init-websocket-url *origin*)) (set! *ws* (call connect: (node-init-websocket-url *origin*) nick)) (log-info* "enclave connect finished" mod: "enclave" phase: phase result: (if *ws* "connected" "failed") elapsed-ms: (- (current-milliseconds) started-at))) (unless *ws* (fail! "CONNECTION_FAILED"))) (define (node-session-start! origin account node-name on-pending on-record on-ready on-error) (define (node-session-resume! record on-ready on-message on-error) (node-session-close!) (set! *origin* (dict-ref record origin: "")) (set! *node-name* (dict-ref record node-name: "")) (set! *node-name* (dict-ref record transport-nick: (dict-ref record node-name: ""))) (set! *token* (dict-ref record token: #f)) (set! *record* record) (set! *on-ready* on-ready) (set! *on-error* on-error) (open! 'auth-connecting *node-name*)) ;; Reopen only the account control plane after a transient failure. The ;; daemon owns the local listener, direct listener, carriers and holders; ;; none of those may be torn down merely because Enclave is unavailable. ;; Keep the established callbacks and reload the short-lived SASL secret ;; from the durable record for this fresh authentication attempt. (define (node-session-reconnect!) (let ((record *record*) (on-ready *on-ready*) (on-message *on-message*) (on-error *on-error*)) (and record (begin (node-session-resume! record on-ready on-message on-error) #t)))) ;; Serialize an IRC command whose final argument is trailing text. Keep ;; this list-driven: native 0.18.x currently misapplies the variadic ;; contract on make-trailing-command when it is reached through `apply`. (loop (cdr rest) (string-append line " " (car rest))))))) (define (node-session-format-command command args) (let loop ((rest args) (line command)) (if (null? rest) line (loop (cdr rest) (string-append line " " (car rest)))))) ;; Returns #f — SILENTLY DROPPING THE COMMAND — whenever the socket is ;; gone or the session is not yet in phase `done`. `run-node-remote!` ;; computes a full RPC response and hands it here, so a #f would mean a mod: "enclave" command: command phase: *phase* socket: (if *ws* "open" "none"))) (and *ws* (eq? *phase* 'done) (if (string=? command "ENCLAVE-RPC") (call send: *ws* (node-session-format-trailing-command command args)) (apply (dict-ref *transport* command:) (cons *ws* (cons command args)))))) ;; Post-auth extension commands use list-driven serializers. Native ;; currently misapplies the variadic command contract through ;; `apply`; ENCLAVE-RPC needs a trailing JSON payload, while ;; DIRECT-OPEN's correlation id is an ordinary middle parameter. (call send: *ws* (if (string=? command "ENCLAVE-RPC") (node-session-format-trailing-command command args) (node-session-format-command command args))))) (define (strip-crlf line) (let loop ((n (string-length line))) (define (begin-auth! completed) (set! *token* (dict-ref completed token: #f)) (set! *record* (node-init-record *origin* completed)) (set! *node-name* (dict-ref completed transport-nick: (dict-ref completed node-name: ""))) (if (*on-record* *record*) (begin (when *ws* (call close: *ws*)) ;; Snapshot the transport once. A native websocket poll may dispatch a ;; callback that changes *ws*; re-reading the global halfway through this ;; poll previously passed #f to irc-ws-receive. (let ((ws *ws*)) (let* ((ws *ws*) ;; The native readiness probe may synchronously dispatch a failed ;; connect callback. That callback clears *ws* while the wrapper ;; is still unwinding, and some native versions then raise while ;; consulting their now-empty inner socket. Probe once, guarded; ;; the identity check below decides whether its answer is usable. (connected (and ws (guard (e (#t #f)) (call connected?: ws))))) (cond ((not ws) #f) ((and (eq? *phase* 'request-connecting) (call connected?: ws)) ((and connected (eq? *phase* 'request-connecting)) (call command: ws "PAIR-REQUEST" *account* *node-name*) (set! *phase* 'request-waiting)) ((and (eq? *phase* 'auth-connecting) (call connected?: ws)) ((and connected (eq? *phase* 'auth-connecting)) (call command: ws "CAP" "LS" "302") (set! *phase* 'auth-cap-ls)) (else #f)) (when (and (eq? ws *ws*) (call connected?: ws)) ;; A synchronous connect refusal makes both the snapshot and *ws* #f. ;; `(eq? #f #f)` is true, so identity alone is not a socket guard. (when (and connected (eq? ws *ws*)) (let loop ((n 0)) (when (< n 16) ;; =============================================================== ;; THE PAIRED RECEIVE TRACE — the wedge instrument. ;; ;; `run-node-remote!` runs OUTSIDE `with-async`, so ;; `(current-scheduler)` is #f, and sigil-websocket only sets the ;; socket non-blocking and only awaits readability ;; `(when (current-scheduler) ...)`. This receive therefore ;; BLOCKS IN THE KERNEL until a frame arrives or the socket ;; closes, with no read timeout anywhere beneath it. ;; ;; That is why these two lines exist instead of a periodic ;; heartbeat in the caller's loop: a heartbeat can only print ;; while messages are arriving, so it goes silent in precisely ;; the failure it was meant to explain. An unmatched ;; `receive-enter` says THE PROCESS STOPPED WHILE INSIDE THIS ;; READ, which is a positive fact where silence is only an ;; absence. ;; ;; READ THE CLAIM NARROWLY — an earlier version of this comment ;; overstated it, and adversarial review was right to object. ;; "Stopped inside the read" has THREE causes, not one: ;; (a) blocked indefinitely — the wedge (folio t-c57a8c); ;; (b) killed while blocked — and note that a perfectly ;; healthy node being restarted by shepherd produces this ;; same signature, so an unmatched enter is NOT by itself ;; evidence of a fault; ;; (c) an exception thrown beneath the read. `parse-irc-message` ;; can blow the native parser's recursive scan limit on a ;; long line (see the STREAM-TICKET note below), and the ;; caller's run-loop has no guard, so the process dies. ;; ;; DISAMBIGUATE BY WHAT FOLLOWS THE UNMATCHED ENTER: ;; nothing, and the pid is still alive -> (a), the wedge; ;; a `--- slate restart ---` marker -> (b) or (c), the ;; process was replaced; check the supervisor's own log, ;; which captures stderr and any backtrace; ;; more lines from the same pid -> not stuck at all. ;; The restart marker is written by `open-log-append!` for ;; exactly this reason. ;; ;; Keep them PAIRED and keep them at the same level. A lone ;; enter-line is the signal; deleting the exit-line, or moving ;; either across a level boundary, destroys the instrument ;; without making anything fail. ;; ;; `debug`, not `trace`: this fires once per inbound CONTROL ;; message, not per byte, so it is affordable at the level the ;; node will actually be run at. ;; =============================================================== (let ((msg (begin (log-debug* "enclave receive-enter" mod: "enclave" phase: *phase*) (call receive: ws)))) (log-debug* "enclave receive-exit" mod: "enclave" phase: *phase*) ;; This executes on every 10ms supervisor turn, including the ;; overwhelmingly common no-data case. The former paired ;; enter/exit diagnostic wrote two log lines per poll and drove ;; significant CPU and disk churn while idle. Connection setup ;; is bounded now, and receive-nowait has its own nonblocking ;; contract, so retain only an anomalous-duration measurement. (let* ((started-at (current-milliseconds)) (msg ((dict-ref *transport* receive-nowait: (dict-ref *transport* receive:)) ws)) (elapsed (- (current-milliseconds) started-at))) (when (> elapsed 50) (log-warn* "slow Enclave nonblocking receive" mod: "enclave" phase: *phase* elapsed-ms: elapsed)) (cond ((and (pair? msg) (or (eq? (car msg) 'stream-ticket-message) (eq? (car msg) 'stream-open-message))) (or (eq? (car msg) 'direct-ticket-message) (eq? (car msg) 'stream-ticket-message) (eq? (car msg) 'stream-open-message) (eq? (car msg) 'relay-ticket-message))) ;; A handler may send an RPC response. Return to the outer ;; event pump before receiving again so the native send ;; continuation and its GC roots are fully unwound.packages/slate-cli/src/slate-cli/enclave-remote.sglmodified
remote-bulk-base64url-encode remote-response-command) (begin (define (drop-fields values count) (if (= count 0) values (and (pair? values) (drop-fields (cdr values) (- count 1))))) (define (remote-rpc-message? msg) (or (and (irc-message? msg) (eq? (irc-message-command msg) 'ENCLAVE-RPC)) ;;; enforced that sender owns this node; the sender account remains the ;;; principal key used by the shared grant engine and its audit trail. ;;; `note-sid!` (optional) is called EXACTLY ONCE per accepted ;;; `stream/open`, as (note-sid! sender request-id sid-or-#f), and it is ;;; how the durable sid reaches the router. It has to be a callback ;;; `stream/open`, as (note-sid! sender request-id sid-or-#f). Resource ;;; downloads use the same callback with the `resource:` routing hint. ;;; This is how the durable sid reaches the router. It has to be a callback ;;; rather than a return value because the sid arrives on `stream/open` ;;; and the TICKET it routes arrives later, on a separate inbound ;;; `STREAM-TICKET` — the two are correlated by (sender, request-id) and ;; carry the sid and must not be used to look for one. The sid ;; is read off the RAW payload, deliberately, because that is ;; the path that does not go through the reader above. (env (if (and verb (string=? verb "stream/open")) (env (if (and verb (or (string=? verb "stream/open") (string=? verb "resource/open"))) #{ id: (or (and request-id (string->number request-id)) request-id) args: #{} } (guard (e (#t #f)) (json-decode payload))))) (and sender request-id verb (dict? env) (if (string=? verb "stream/open") (cond ((string=? verb "resource/open") ;; Resource streams never reserve a durable session ;; holder, even when the session quota is full. (note-sid! sender request-id resource:) (list "STREAM-TICKET" sender request-id)) ((string=? verb "stream/open") ;; THE NARROW SCRAPE. `scrape-sid` is a flat, single-pass ;; scan that never invokes the recursive native JSON ;; reader — the reader the comment above records as "stream/open" (json-encode #{ control: #{ t: "err" code: "bad-args" message: "sid is malformed" } }))))) (let* ((id (dict-ref env id: request-id)) (req (dict-set (dict-set env id: id) verb: verb)) ;; Enclave proves identity/ownership; the node still ;; decides capability. Unknown account principals are ;; denied by the ordinary grant engine. (remote-config (make-config grants: (list (cons sender (config-silent-pair-grants config))))) (grants (build-principal-grants remote-config sender)) (reply (dispatch-req #{ config: remote-config } sender grants req)) (control (reply-control reply)) (bulk (reply-bulk reply)) (body (json-encode (if bulk #{ control: control bulk-base64url: (remote-bulk-base64url-encode bulk) } #{ control: control })))) (remote-response-command sender request-id verb body))))))) message: "sid is malformed" } })))))) (else (let* ((id (dict-ref env id: request-id)) (req (dict-set (dict-set env id: id) verb: verb)) ;; Enclave proves identity/ownership; the node still ;; decides capability. Unknown account principals are ;; denied by the ordinary grant engine. (remote-config (make-config grants: (list (cons sender (config-silent-pair-grants config))))) (grants (build-principal-grants remote-config sender)) (reply (dispatch-req #{ config: remote-config } sender grants req)) (control (reply-control reply)) (bulk (reply-bulk reply)) (body (json-encode (if bulk #{ control: control bulk-base64url: (remote-bulk-base64url-encode bulk) } #{ control: control })))) (remote-response-command sender request-id verb body)))))))) ;;; Enclave returns both opaque tickets only to the authenticated node. The ;;; node consumes its half locally and sends only the client half back over (let* ((client (list-ref p 0)) (request-id (list-ref p 1)) (client-ticket (list-ref p 2)) (node-ticket (list-ref p 3)) (ttl (list-ref p 4)) (outcome (route-ticket client request-id node-ticket))) (metadata (if (= (length p) 13) (drop-fields p 5) '())) (outcome (route-ticket client request-id node-ticket metadata))) (if (dict? outcome) (remote-response-command client request-id "stream/open" (json-encode code: (dict-ref outcome code: "unavailable") message: (dict-ref outcome message: "stream unavailable") } })) (remote-response-command client request-id "stream/ticket" (json-encode #{ ticket: client-ticket expires-in: ttl })))))))) (json-encode #{ ticket: client-ticket expires-in: ttl pairing-descriptors: metadata })))))))) ))packages/slate-cli/src/slate-cli/enclave-stream.sglmodified
;;; `/stream` node endpoint. Adapts the existing session hub to an opaque,;;; identity-ticketed Enclave WebSocket; Enclave never decodes these frames.(define-library (slate-cli enclave-stream) (import (sigil io) (sigil json) (sigil string) (sigil async) (import (sigil io) (sigil json) (sigil string) (sigil array) (sigil async) (only (sigil math) quotient) (sigil log) (sigil time) ;; UNBOUND VARIABLE swallows the error and reports a successful ;; unlink that never happened — a silent failure of exactly the kind ;; this phase exists to remove. (sigil fs) (sigil fs) (sigil path) (sigil socket) (sigil websocket) (sigil websocket frame) (slate-cli config) (slate-cli listen) (slate-cli sessions) (slate-cli config) (slate-cli dispatch) (slate-cli listen) (slate-cli sessions) (slate-cli terminal discovery) (slate-cli terminal owner) (slate-cli resource transfer connection) (slate-cli node pairing) (slate-cli node pairing-carrier) (slate-cli wire)) (export enclave-stream-start! enclave-stream-poll! enclave-stream-listen! (export enclave-stream-start! enclave-stream-start-on-hub! enclave-stream-poll! enclave-stream-listen! enclave-stream-unlisten! enclave-stream-start-reaper! enclave-stream-control-frame enclave-stream-control-text) enclave-stream-control-frame enclave-stream-control-text ;; Focused relay Resource-plane test seams. These drive the same ;; holder adapter used by /stream without opening a network socket. make-enclave-stream-test-holder enclave-stream-test-text! enclave-stream-test-binary! enclave-stream-test-close!) (begin (define *streams* '()) ;; Sessions belong to the authenticated node, not to one relay socket. ;;; spawns that way; `test-logging-contract.sgl` pins the 3-arg arity). ;;; A holder GIVEN a path that will not bind has been asked, and failed, ;;; and must die loudly before a single session exists to be stranded. (define (enclave-stream-listen! path config) (define (enclave-stream-listen! path config . hub) (when (pair? hub) (set! *session-hub* (car hub))) (set! *ctl-config* config) (guard (e (#t (log-error "holder control socket UNAVAILABLE; refusing to park unreachable" mod: "stream" path: path) #f)) (let ((l (unix-listen path))) (socket-set-non-blocking! l #t) (set! *ctl-listener* l) (set! *ctl-path* path) (log-info* "holder listening for tickets" mod: "stream" path: path) #t))) (if (and *ctl-listener* (equal? path *ctl-path*)) #t (guard (e (#t (log-error "holder control socket UNAVAILABLE; refusing to park unreachable" mod: "stream" path: path) #f)) (let ((l (unix-listen path))) (socket-set-non-blocking! l #t) (set! *ctl-listener* l) (set! *ctl-path* path) (log-info* "holder listening for tickets" mod: "stream" path: path) #t)))) ;;; Unlink the socket on the way out. The control node also sweeps stale ;;; sockets at startup, because a holder killed with SIGKILL never runs ;; Answer one control line on the seam. The router writes it and closes. ;; ;; TWO VERBS, and the split is deliberate: ;; Routing and discovery stay separate: ;; ;; OWNS <sid> -> "OWNS <sid> yes" | "OWNS <sid> no" ;; TICKET <ws-url> <client> <ticket> -> dial a replacement /stream ;; LIST <JSON principal> -> local, principal-filtered summaries ;; ;; THE QUERY CARRIES NO CREDENTIAL, AND THAT IS A SECURITY PROPERTY, not ;; a tidiness one. The control node must find out WHICH of its holders owns (string-append "OWNS " sid (if (hub-holds-sid? *session-hub* sid) " yes" " no") "\n")))) ((string-starts-with? line "LIST ") (let ((principal (guard (e (#t #f)) (json-decode (substring line 5 (string-length line)))))) (when (string? principal) (ctl-reply! (string-append (json-encode #{ sessions: (list->array (hub-session-summaries *session-hub* principal)) }) "\n"))))) (else (log-warn* "unrecognised control line" mod: "stream"))))) (define (ctl-drop!) (enclave-stream-control-frame (utf8->string (ws-frame-payload f)))) (ws-send-binary ws (ws-frame-payload f)))))) (define (enclave-stream-start! websocket-url client ticket config) (define (holder-hub-verb? verb) (and (string? verb) (or (and (>= (string-length verb) 8) (string=? (substring verb 0 8) "session/")) (and (>= (string-length verb) 6) (string=? (substring verb 0 6) "agent/"))))) ;;; Relay Resource requests use the same dispatcher as the direct listener. ;;; The holder's virtual connection owns the transport-specific deferred ;;; write slot and watch handles, exactly as a direct connection does. (define (holder-on-text daemon conn text) (let ((env (and (<= (string-length text) 65536) (decode-control text)))) (unless (pairing-carrier-handle! conn text) (if (and env (equal? (dict-ref env t: #f) "req") (not (holder-hub-verb? (dict-ref env verb: "")))) (holder-send-reply! conn (dispatch-req daemon (conn-origin conn) (conn-state-get conn "grants" #f) env)) (if (conn-state-get conn "resource-only" #f) (ws-send-control! conn #{ t: "err" id: (if env (dict-ref env id: 0) 0) code: "unsupported" message: "resource streams do not support sessions" }) (sessions-on-text daemon conn text)))))) (define (holder-send-reply! conn reply) (let ((pending (reply-pending-write reply)) (transfer (reply-transfer reply))) (cond (pending (conn-state-set! conn "pending-write" pending)) (transfer (let ((result (resource-transfer-open! conn (conn-state-get conn "grants" #f) transfer))) (when (and (conn-state-get conn "resource-only" #f) (not (eq? (dict-ref result status: #f) 'ready))) (resource-finished! conn)))) (else (let ((sub (reply-subscription reply))) (when sub (holder-watch-start! conn sub))) (let ((cancel (reply-cancel reply))) (when cancel (holder-watch-stop! conn cancel))) (let ((control (reply-control reply))) (when control (ws-send-control! conn control))) (let ((bulk (reply-bulk reply))) (when bulk (ws-send-binary! conn (encode-stream-frame frame-bulk (reply-bulk-id reply) bulk)))) (when (conn-state-get conn "resource-only" #f) (resource-finished! conn)))))) (define (resource-finished! conn) ;; Sending END does not mean the browser has consumed it. Let its close ;; acknowledge receipt; bound abandoned workers if that close never comes. (conn-state-set! conn "resource-done" (current-milliseconds))) ;;; Non-control relay frames retain their ordinary node framing. A 0x10 ;;; frame completes a holder-owned deferred write; every other frame stays ;;; on the unchanged Session lane. (define (holder-on-binary daemon conn bytes) (let ((frame (decode-stream-frame bytes))) (when frame (cond ((= (stream-frame-type frame) frame-bulk) (let* ((id (stream-frame-id frame)) (pending (conn-state-get conn "pending-write" #f))) (if (and pending (equal? (dict-ref pending id: #f) id)) (begin ;; Consume before dispatch: a raising/invalid body can ;; never be replayed into the same authority slot. (conn-state-set! conn "pending-write" #f) (holder-send-reply! conn (dispatch-bulk-write (conn-state-get conn "grants" #f) id (dict-ref pending path: "") (stream-frame-payload frame)))) (ws-send-control! conn #{ t: "err" id: id code: "no-pending-bulk" message: "no fs/write-file awaiting this id" })))) ((resource-transfer-frame? frame) (let ((result (resource-transfer-frame! conn frame))) (when (and (conn-state-get conn "resource-only" #f) (member (dict-ref result status: #f) '(complete cancelled refused))) (resource-finished! conn)))) (else (unless (conn-state-get conn "resource-only" #f) (sessions-handle-binary daemon conn bytes))))))) (define holder-watch-poll-interval 1) (define (holder-watch-start! conn sub) (let ((id (dict-ref sub sub: #f)) (path (dict-ref sub path: "")) (watcher (dict-ref sub watcher: #f))) (conn-state-set! conn "watches" (cons (cons id watcher) (conn-state-get conn "watches" '()))) (go (holder-watch-loop conn id watcher path)))) (define (holder-watch-stop! conn sub) (let ((watcher (holder-watch-find conn sub))) (when watcher (watch-close watcher)) (conn-state-set! conn "watches" (holder-watch-remove conn sub)))) (define (holder-watch-loop conn sub watcher path) (guard (e (#t #f)) (let loop () (sleep holder-watch-poll-interval) (when (holder-watch-find conn sub) (let ((changed (watch-changes watcher))) (when (pair? changed) (ws-send-control! conn #{ t: "ev" sub: sub event: #{ kind: "change" path: path changed: changed } })) (loop)))))) (define (holder-watch-find conn sub) (assv-ref sub (conn-state-get conn "watches" '()) #f)) (define (holder-watch-remove conn sub) (let loop ((watches (conn-state-get conn "watches" '())) (out '())) (cond ((null? watches) (reverse out)) ((eqv? (car (car watches)) sub) (loop (cdr watches) out)) (else (loop (cdr watches) (cons (car watches) out)))))) (define (holder-close! daemon conn) (pairing-carrier-close! conn) (resource-transfer-close! conn 'connection-lost) (for-each (lambda (entry) (guard (e (#t #f)) (watch-close (cdr entry)))) (conn-state-get conn "watches" '())) (conn-state-set! conn "watches" '()) (conn-state-set! conn "pending-write" #f) (sessions-conn-closed! daemon conn)) (define (enclave-stream-start-on-hub! websocket-url client ticket config hub . maybe-pairing-meta) (let* ((ws (ws-connect (stream-url websocket-url))) ;; `session-ttl:` is carried across explicitly. This derived config ;; is what the daemon dict hands to `do-open`, so it is what state-dir: (config-state-dir config) grants: (list (cons client (config-silent-pair-grants config))) session-ttl: (config-session-ttl-arg config))) (daemon #{ config: (dict-set remote-config agent-service: #t) sessions: *session-hub* }) (daemon #{ config: (dict-set remote-config agent-service: #t) sessions: hub session-backend: (make-terminal-backend config) session-directory: (vector (lambda (principal mine) (terminal-discover (path-join (config-state-dir config) "holders") *ctl-path* principal mine))) }) (conn (make-virtual-conn (lambda (frame) (outbound! ws frame)) client)) ;; Native ws-connect completes its handshake synchronously, but ;; ws-connected? is not promoted until a receive poll. Redeem the ;; authenticated account. Never derive this from client request JSON. (conn-state-set! conn "authed?" #t) (ws-send ws ticket) (conn-state-set! conn "grants" (build-principal-grants remote-config client)) (let* ((meta (and (pair? maybe-pairing-meta) (car maybe-pairing-meta))) (descriptors (and (dict? meta) (dict-ref meta descriptors: '()))) (fields (cond ((list? descriptors) descriptors) ((array? descriptors) (array->list descriptors)) (else '()))) (identity (and (= (length fields) 8) (pairing-load-identity (config-state-dir config))))) (when (and (dict? meta) (dict-ref meta resource-only?: #f)) (conn-state-set! conn "resource-only" #t)) (when identity (pairing-carrier-init! conn 'acceptor #{ session-id: (dict-ref meta request-id: "") caller-bot-id: (list-ref fields 0) caller-transport-nick: (dict-ref meta caller-transport-nick: client) caller-key-id: (list-ref fields 1) caller-generation: (string->number (list-ref fields 2)) caller-public-key: (list-ref fields 3) acceptor-bot-id: (list-ref fields 4) acceptor-transport-nick: (dict-ref meta acceptor-transport-nick: "") acceptor-key-id: (list-ref fields 5) acceptor-generation: (string->number (list-ref fields 6)) acceptor-public-key: (list-ref fields 7) } identity '() (config-state-dir config)))) (set! *streams* (cons st *streams*)) ;; The TICKET IS A BEARER CAPABILITY and is never logged. See ;; main.sgl's safe-command-summary: the child's argv carries it, and ;; enters its steady state. st)) (define (enclave-stream-start! websocket-url client ticket config . pairing-meta) (enclave-stream-start-on-hub! websocket-url client ticket config *session-hub* (if (pair? pairing-meta) (car pairing-meta) #f))) ;; Take at most one message WITHOUT ever parking. ;; ;; `ws-receive-nowait` EXCLUSIVELY. Plain `ws-receive` parks, and there is (define (take-message! ws) (let ((m (ws-receive-nowait ws))) ;; Starred macro: the classification is only computed when debug is ;; actually enabled. This sits on the session data plane, so an ;; unguarded call would format a string per frame. (log-debug* "stream recv" mod: "stream" got: (cond ((ws-message? m) "message") ((eq? m 'closed) "CLOSED") ((not m) "none") (else "other")) connected: (if (ws-connected? ws) "y" "n")) m)) ;; Drain up to 128 frames from one stream. Returns #t while the stream is (vector-set! st 1 #f) (vector-set! st 2 #t)) (let loop ((n 0)) (if (>= n 128) #t ; hit the batch cap; still live (cond ((let ((finished (conn-state-get (vector-ref st 4) "resource-done" #f))) (and finished (>= (- (current-milliseconds) finished) 30000))) #f) ((>= n 128) #t) ; hit the batch cap; still live (else (let ((m (take-message! ws))) (cond ((ws-message? m) (when (eq? (ws-message-type m) 'binary) (when (and (not (conn-state-get (vector-ref st 4) "resource-done" #f)) (eq? (ws-message-type m) 'binary)) (let* ((bytes (ws-message-data m)) (fr (decode-stream-frame bytes))) (when fr (if (= (stream-frame-type fr) 1) (sessions-on-text (vector-ref st 3) (vector-ref st 4) (holder-on-text (vector-ref st 3) (vector-ref st 4) (enclave-stream-control-text bytes)) (sessions-handle-binary (vector-ref st 3) (vector-ref st 4) bytes))))) (holder-on-binary (vector-ref st 3) (vector-ref st 4) bytes))))) (loop (+ n 1))) ((eq? m 'closed) (log-info* "stream reported CLOSED by the relay" mod: "stream") #f) ; relay closed the stream: dead (else #t))))))) ; #f / no data: live, idle (else #t)))))))) ; #f / no data: live, idle ;; Poll every live stream, drop the ones that reported closed, and return ;; #t while any stream remains live. The caller exits its loop on #f so a ;; R1 -- only the local listener wired it -- which is one of the reasons ;; remote reattach could not work even in principle. (define (retire-stream! st) (guard (e (#t #f)) (ws-close (vector-ref st 0))) (guard (e (#t #f)) (sessions-conn-closed! (vector-ref st 3) (vector-ref st 4))) (holder-close! (vector-ref st 3) (vector-ref st 4))) (log-info* "stream closed; sessions detached" mod: "stream" sessions: (hub-session-count *session-hub*))) (let keep ((xs *streams*) (out '())) (if (null? xs) (reverse out) (let ((alive? (poll-one! (car xs)))) (let ((alive? (guard (e (#t (log-debug* "stream poll failed; closing connection" mod: "stream") #f)) (poll-one! (car xs))))) (unless alive? (retire-stream! (car xs))) (keep (cdr xs) (if alive? (cons (car xs) out) out)))))) (or (pair? *streams*) (> (hub-session-count *session-hub*) 0))) ;; Network-free holder constructor and drivers for focused tests. (define (make-enclave-stream-test-holder config client send . resource-only) (let* ((daemon #{ config: config sessions: (make-session-hub) }) (conn (make-virtual-conn send client))) (conn-state-set! conn "grants" (build-principal-grants config client)) (when (and (pair? resource-only) (car resource-only)) (conn-state-set! conn "resource-only" #t)) (vector daemon conn))) (define (enclave-stream-test-text! holder text) (holder-on-text (vector-ref holder 0) (vector-ref holder 1) text)) (define (enclave-stream-test-binary! holder bytes) (holder-on-binary (vector-ref holder 0) (vector-ref holder 1) bytes)) (define (enclave-stream-test-close! holder) (holder-close! (vector-ref holder 0) (vector-ref holder 1))) ))packages/slate-cli/src/slate-cli/git/branch.sgladded
;;; (slate-cli git branch) — bounded local refs and non-forcing branch operations.(define-library (slate-cli git branch) (import (sigil math) (sigil io) (sigil string) (sigil json) (sigil system grant) (slate-cli git exec) (slate-cli git status) (slate-cli git log)) (export git-branches git-switch-branch git-create-branch git-branch-before-publish-hook!) (begin (define *before-publish* #f) (define (git-branch-before-publish-hook! hook) (set! *before-publish* hook)) (define (run grants cwd args) (let ((result (git-exec grants cwd args output-limit: 262144))) (unless (and (= (dict-ref result exit: -1) 0) (not (dict-ref result timeout?: #f)) (not (dict-ref result output-exceeded?: #f)) (not (dict-ref result read-error?: #f))) (let* ((message (string-trim (dict-ref result stderr: ""))) (short (substring message 0 (min 1600 (string-length message))))) (error "operation-failed" (if (string=? short "") "Git branch operation failed or exceeded its limits" short)))) (dict-ref result stdout: ""))) (define (branch-name! grants cwd name) ;; Full refs prevent revision expressions and option/checkout shorthand. (unless (and (string? name) (> (string-length name) 0) (<= (string-length name) 240) (not (string-starts-with? name "-")) (not (string=? name "HEAD"))) (error "operation-failed" "A valid branch name is required")) (run grants cwd (list "check-ref-format" (string-append "refs/heads/" name))) name) (define (bounded reply) (when (> (bytevector-length (string->utf8 (json-encode reply))) 262144) (error "operation-failed" "Branch listing exceeds its byte limit")) reply) (define (branch-record line) (let ((fields (git-split-nul line))) (unless (and (>= (length fields) 3) (git-commit-oid? (cadr fields))) (error "operation-failed" "Invalid branch record")) #{ name: (car fields) oid: (cadr fields) upstream: (list-ref fields 2) })) (define (git-branches grants args) (let* ((repo (dict-ref args repo: #f)) (status (git-status grants #{ repo: repo recent-limit: 0 })) (text (run grants (dict-ref repo worktree: "") (list "for-each-ref" "--sort=refname" "--count=501" "--format=%(refname:lstrip=2)%00%(objectname)%00%(upstream:short)%00" "refs/heads/"))) (rows (map branch-record (filter (lambda (line) (not (string=? line ""))) (string-split text "\n"))))) ;; Ref names cannot contain newlines. The extra row detects truncation ;; instead of silently offering an incomplete branch chooser. (when (> (length rows) 500) (error "operation-failed" "This branch chooser supports up to 500 local branches")) (bounded #{ version: 1 repo: repo snapshot: (dict-ref status snapshot: #{}) branch: (dict-ref status branch: #{}) branches: rows }))) (define (validate-status! grants args) (let* ((repo (dict-ref args repo: #f)) (status (git-status grants #{ repo: repo recent-limit: 0 })) (branch (dict-ref status branch: #{}))) ;; Branch changes need repository-wide writes, including linked ;; worktree administration. Narrow file-only grants are insufficient. (grant-assert! grants 'fs-write (dict-ref repo worktree: "")) (grant-assert! grants 'fs-write (dict-ref repo common-dir: "")) (unless (and (git-snapshot=? (dict-ref args expected-snapshot: #f) (dict-ref status snapshot: #f)) (equal? (dict-ref args expected-branch: #f) (dict-ref branch head: #f))) (error "stale-snapshot" "Repository changed; reopen the branch menu")) (when (dict-ref status operation: #f) (error "operation-failed" "Finish the current Git operation before changing branches")) status)) (define (branch-oid grants cwd name) (string-trim (run grants cwd (list "show-ref" "--verify" "--hash" (string-append "refs/heads/" name))))) (define (result grants repo name switched?) ;; A successful mutation is not turned into a reported refusal if the ;; subsequent status read fails. The client can explicitly refresh then. #{ version: 1 repo: repo name: name switched: switched? applied: #t status: (guard (e (#t #f)) (git-status grants #{ repo: repo recent-limit: 20 })) }) (define (git-switch-branch grants args) (let* ((before (validate-status! grants args)) (repo (dict-ref args repo: #{})) (cwd (dict-ref repo worktree: "")) (name (branch-name! grants cwd (dict-ref args name: #f))) (expected (dict-ref args target-oid: #f))) (unless (and (git-commit-oid? expected) (equal? expected (branch-oid grants cwd name))) (error "stale-snapshot" "Target branch moved; reopen the branch menu")) (when *before-publish* (*before-publish*)) (validate-status! grants args) (unless (equal? expected (branch-oid grants cwd name)) (error "stale-snapshot" "Target branch moved; reopen the branch menu")) ;; Git refuses conflicting edits and branches held by another worktree. ;; No force, merge, detach, guess, or auto-stash fallback is permitted. (run grants cwd (list "switch" "--no-guess" "--" name)) (result grants repo name #t))) (define (git-create-branch grants args) (let* ((before (validate-status! grants args)) (repo (dict-ref args repo: #{})) (cwd (dict-ref repo worktree: "")) (name (branch-name! grants cwd (dict-ref args name: #f))) (start (dict-ref args start-oid: #f)) (switch? (dict-ref args switch: #f)) (head (dict-ref (dict-ref before snapshot: #{}) head-oid: ""))) (unless (and (boolean? switch?) (or (git-commit-oid? start) (and switch? (equal? start "") (string=? head "")))) (error "operation-failed" "Branch creation requires a commit")) (unless (string=? start "") (run grants cwd (list "cat-file" "-e" (string-append start "^{commit}")))) (when *before-publish* (*before-publish*)) (validate-status! grants args) ;; switch -c creates the ref only if checkout succeeds. branch without ;; -f never replaces an existing ref, including slash-separated names. (if switch? (run grants cwd (append (list "switch" "--no-guess" "--no-track" "-c" name) (if (string=? start "") '() (list start)))) (run grants cwd (list "branch" "--no-track" "--" name start))) (result grants repo name switch?)))))packages/slate-cli/src/slate-cli/git/diff.sgladded
;;; (slate-cli git diff) — node-owned, bounded structured Git diff pages.(define-library (slate-cli git diff) (import (sigil core) (sigil io) (sigil math) (sigil string) (sigil crypto) (sigil json) (sigil process) (sigil async) (sigil time) (sigil log) (sigil system grant) (slate-cli git exec) (slate-cli git status) (slate-cli logging) (slate-cli git log)) (export git-diff git-diff-records git-diff-active-stream-count) (begin (define page-line-ceiling 400) (define page-byte-ceiling 262144) (define *diff-streams* #{}) (define stream-lifetime-seconds 30) (define profile-enabled? (equal? (getenv "SLATE_GIT_PROFILE") "1")) (define (profile-phase name thunk) (if (not profile-enabled?) (thunk) (let* ((start (op-clock)) (result (thunk))) (log-debug* "node/git-diff-phase" mod: "git" phase: name duration-ms: (op-elapsed-ms start)) result))) (define (git-diff-active-stream-count) (length (dict-keys *diff-streams*))) (define (stream-token) (string-append "diff-stream-" (base64url-encode (random-bytes 24)))) (define (stream-remove! token stream kill?) (let ((closed (vector-ref stream 10))) (unless (vector-ref closed 0) (vector-set! closed 0 #t) (set! *diff-streams* (dict-remove *diff-streams* token)) (let ((proc (vector-ref stream 0))) (when (and kill? (process-alive? proc)) (process-kill! proc)) (guard (e (#t #f)) (process-wait proc)))))) (define (drain-stderr token stream port) (let loop ((bytes 0)) (let ((ch (read-char port))) (unless (eof-object? ch) (let ((next (+ bytes (bytevector-length (string->utf8 (string ch)))))) (if (> next 65536) (stream-remove! token stream #t) (loop next))))))) (define (expire-stream token stream) (let loop () (when (and (not (vector-ref (vector-ref stream 10) 0)) (< (current-second) (vector-ref stream 9))) (sleep 0.05) (loop))) (unless (vector-ref (vector-ref stream 10) 0) (stream-remove! token stream #t))) (define (valid-limit value ceiling label) (if (and (integer? value) (> value 0) (<= value ceiling)) value (error "operation-failed" (string-append label " exceeds v1 ceiling")))) (define (snapshot=? a b) (and (dict? a) (dict? b) (equal? (dict-ref a head-oid: #f) (dict-ref b head-oid: #f)) (equal? (dict-ref a index-id: #f) (dict-ref b index-id: #f)) (equal? (dict-ref a worktree-id: #f) (dict-ref b worktree-id: #f)))) (define (line-kind line) (cond ((string-starts-with? line "diff --git ") file:) ((string-starts-with? line "@@ ") hunk:) ((string-starts-with? line "+") addition:) ((string-starts-with? line "-") deletion:) ((string-starts-with? line " ") context:) ((string-starts-with? line "Binary files ") binary:) ((or (string-starts-with? line "old mode ") (string-starts-with? line "new mode ")) mode:) ((or (string-starts-with? line "rename from ") (string-starts-with? line "rename to ")) rename:) (else meta:))) (define (range-start token) (let* ((body (if (> (string-length token) 1) (substring token 1 (string-length token)) "0")) (parts (string-split body ","))) (or (string->number (car parts)) 0))) (define (range-count token) (let* ((body (if (> (string-length token) 1) (substring token 1 (string-length token)) "0")) (parts (string-split body ","))) (if (null? (cdr parts)) 1 (or (string->number (cadr parts)) 0)))) (define (hunk-starts line) (let ((parts (string-split line " "))) (if (>= (length parts) 3) (cons (range-start (list-ref parts 1)) (range-start (list-ref parts 2))) (cons 0 0)))) (define (decorate-hunk record line) (if (not (eq? (dict-ref record kind: #f) hunk:)) record (let ((parts (string-split line " "))) (if (< (length parts) 3) record (dict-set (dict-set (dict-set (dict-set (dict-set record old-start: (range-start (list-ref parts 1))) old-lines: (range-count (list-ref parts 1))) new-start: (range-start (list-ref parts 2))) new-lines: (range-count (list-ref parts 2))) context-hash: (sha256 line)))))) (define (git-diff-records text) (let loop ((lines (string-split text "\n")) (old-line 0) (new-line 0) (out '())) (if (null? lines) (reverse out) (let* ((line (car lines)) (kind (line-kind line)) (old (if (or (eq? kind deletion:) (eq? kind context:)) old-line #f)) (new (if (or (eq? kind addition:) (eq? kind context:)) new-line #f)) (record (decorate-hunk #{ kind: kind text: line old-line: old new-line: new } line))) (let ((starts (and (eq? kind hunk:) (hunk-starts line)))) (loop (cdr lines) (if starts (car starts) (if old (+ old-line 1) old-line)) (if starts (cdr starts) (if new (+ new-line 1) new-line)) (cons record out))))))) (define (diff-argv kind target) (let ((base (list "diff" "--no-ext-diff" "--no-color" "--find-renames" "--unified=3" "--no-textconv"))) (cond ((string=? kind "worktree") (append base (if target (list "--" target) '()))) ((string=? kind "untracked") (if (and target (string? target)) (list "diff" "--no-ext-diff" "--no-color" "--unified=3" "--no-textconv" "--no-index" "--" "/dev/null" target) (error "operation-failed" "untracked diff requires a path"))) ((string=? kind "staged") (append base (list "--cached") (if target (list "--" target) '()))) ((string=? kind "commit") (if (git-commit-oid? target) (list "show" "--format=" "--root" "--first-parent" "--no-ext-diff" "--no-color" "--no-textconv" "--find-renames" "--unified=3" target "--") (error "operation-failed" "commit diff requires an oid"))) ((string=? kind "conflict-stage") (let ((path (and (dict? target) (dict-ref target path: #f))) (stage (and (dict? target) (dict-ref target stage: #f)))) (unless (and (string? path) (member stage (list 1 2 3))) (error "operation-failed" "conflict stage requires a path and stage 1, 2, or 3")) ;; A stage visit remains a bounded structured diff. Base compares ;; ours→base; ours/theirs compare base→that side. (let ((left (if (= stage 1) 2 1)) (right stage)) (append base (list (string-append ":" (number->string left) ":" path) (string-append ":" (number->string right) ":" path) "--"))))) (else (error "operation-failed" "unsupported diff kind"))))) ;; Stream state: [process stdout old-line new-line continued-kind ;; repo snapshot kind target deadline closed-box ;; read-buffer read-position]. Git is ;; started once; cursors name this node-held stream, so later pages neither ;; rerun Git nor materialize/drop the complete prefix. (define (stream-open grants worktree argv repo snapshot kind target) (let* ((proc (git-spawn grants worktree argv)) ;; process-wait closes pipe descriptors that have not yet been ;; claimed as ports. Claim stderr before scheduling its drainer: ;; a short diff can otherwise finish before that task first runs. (stderr (process-stderr proc)) (token (stream-token)) (stream (vector proc (process-stdout proc) 0 0 #f repo snapshot kind target (+ (current-second) stream-lifetime-seconds) (vector #f) #f 0))) (set! *diff-streams* (dict-set *diff-streams* token stream)) (when (in-async-context?) (go (drain-stderr token stream stderr)) (go (expire-stream token stream))) (cons token stream))) (define (stream-ref cursor) (and (string? cursor) (dict-ref *diff-streams* cursor #f))) (define (stream-request=? stream repo snapshot kind target) (and (equal? (vector-ref stream 5) repo) (snapshot=? (vector-ref stream 6) snapshot) (string=? (vector-ref stream 7) kind) (equal? (vector-ref stream 8) target))) ;; Keep each buffered line as UTF-8 bytes so page boundaries do not walk ;; or allocate every character, even for a multi-megabyte source line. (define (stream-ready-buffer stream) (or (vector-ref stream 11) ;; read-string waits for its entire request on process ports and can ;; deadlock behind a full child pipe. The runtime buffers read-line. (let ((line (read-line (vector-ref stream 1)))) (if (eof-object? line) line (let ((bytes (string->utf8 line))) (vector-set! stream 11 bytes) (vector-set! stream 12 0) bytes))))) (define (utf8-page-end buffer position remaining) (let ((size (bytevector-length buffer))) (let loop ((end (min size (+ position remaining)))) (if (or (= end size) (= end position)) end (let ((byte (bytevector-u8-ref buffer end))) (if (and (>= byte 128) (< byte 192)) (loop (- end 1)) end)))))) (define (record-from-line stream line continued?) (let* ((remembered (vector-ref stream 4)) (kind (or remembered (line-kind line))) (old-line (vector-ref stream 2)) (new-line (vector-ref stream 3)) (old (if (or (eq? kind deletion:) (eq? kind context:)) old-line #f)) (new (if (or (eq? kind addition:) (eq? kind context:)) new-line #f)) (record (decorate-hunk #{ kind: kind text: line old-line: old new-line: new continued: continued? } line))) (if continued? (vector-set! stream 4 kind) (begin (vector-set! stream 4 #f) (let ((starts (and (eq? kind hunk:) (hunk-starts line)))) (vector-set! stream 2 (if starts (car starts) (if old (+ old-line 1) old-line))) (vector-set! stream 3 (if starts (cdr starts) (if new (+ new-line 1) new-line)))))) record)) (define (stream-finish token stream out) (if (vector-ref (vector-ref stream 10) 0) (error "operation-failed" "Git diff stream expired or was cancelled") (let ((exit (process-wait (vector-ref stream 0)))) (set! *diff-streams* (dict-remove *diff-streams* token)) (vector-set! (vector-ref stream 10) 0 #t) (unless (or (= exit 0) (and (string=? (vector-ref stream 7) "untracked") (= exit 1))) (error "operation-failed" "Git diff failed")) #{ records: (reverse out) complete: #t }))) (define (stream-page token stream max-lines max-bytes) ;; Reserve space for record keys, identity, quoting and the RPC envelope. ;; The final encoded-size assertion remains authoritative. Four bytes ;; guarantee progress for a full UTF-8 character on a fresh page. (let ((content-limit (max 4 (min 196608 (- max-bytes 65536))))) (let loop ((page-bytes 0) (count 0) (out '())) (if (or (= count max-lines) (>= page-bytes content-limit)) #{ records: (reverse out) complete: #f } (let ((buffer (stream-ready-buffer stream))) (if (eof-object? buffer) (stream-finish token stream out) (let* ((position (vector-ref stream 12)) (size (bytevector-length buffer)) (end (utf8-page-end buffer position (- content-limit page-bytes)))) (if (and (= end position) (< position size)) #{ records: (reverse out) complete: #f } (let* ((continued? (< end size)) (line (utf8->string (if (and (= position 0) (= end size)) buffer (bytevector-copy buffer position end)))) (record (record-from-line stream line continued?))) (vector-set! stream 12 end) (unless continued? (vector-set! stream 11 #f)) (loop (+ page-bytes (- end position)) (+ count 1) (cons record out))))))))))) (define (encoded-bytes value) (bytevector-length (string->utf8 (json-encode value)))) (define (git-diff grants args) (let* ((repo (dict-ref args repo: #f)) (snapshot (dict-ref args snapshot: #f)) (worktree (and (dict? repo) (dict-ref repo worktree: #f))) (kind (dict-ref args kind: #f)) (target (dict-ref args target: #f)) (cursor (dict-ref args cursor: #f)) (cancel? (dict-ref args cancel: #f)) (max-lines (valid-limit (dict-ref args max-lines: 0) page-line-ceiling "max-lines")) (max-bytes (valid-limit (dict-ref args max-bytes: 0) page-byte-ceiling "max-bytes"))) (unless (and (string? worktree) (string? kind) (dict? snapshot)) (error "operation-failed" "invalid diff request")) (grant-assert! grants 'fs-read worktree) (if cancel? (begin (let ((stream (stream-ref cursor))) (when stream (stream-remove! cursor stream #t))) #{ version: 1 cancelled: #t cursor: cursor }) (begin (let ((current (dict-ref (profile-phase "validate" (lambda () (git-status grants #{ repo: repo recent-limit: 0 }))) snapshot: #f))) (unless (snapshot=? snapshot current) (error "stale-snapshot" "diff snapshot is stale"))) (let* ((opened (if cursor (let ((stream (stream-ref cursor))) (if (and stream (stream-request=? stream repo snapshot kind target)) (cons cursor stream) (error "stale-snapshot" "diff cursor expired"))) (profile-phase "open" (lambda () (stream-open grants worktree (diff-argv kind target) repo snapshot kind target))))) (token (car opened)) (stream (cdr opened)) (page (profile-phase "page" (lambda () (stream-page token stream max-lines max-bytes)))) (records (dict-ref page records: '())) (complete? (dict-ref page complete: #f)) (reply #{ version: 1 repo: repo snapshot: snapshot files: (if (null? records) '() (list #{ id: (sha256 (string-append kind ":" (or target "*"))) kind: kind target: target hunks: records })) next-cursor: (if complete? #f token) complete: complete? truncation: (if complete? #f #{ reason: "page-ceiling" }) })) (let ((size (profile-phase "encode" (lambda () (encoded-bytes reply))))) (when (> (+ size 256) max-bytes) (stream-remove! token stream #t) (error "operation-failed" "diff reply exceeded byte ceiling")) (dict-set reply payload-bytes: size)))))))))packages/slate-cli/src/slate-cli/git/exec.sgladded
;;; (slate-cli git exec) — portable, bounded Git process execution.;;;;;; This is the sole place the Git domain starts a process. It deliberately;;; uses Sigil's ordinary captured-port process API, which is implemented on;;; Linux, macOS, and Windows. The tempting raw-pipe API is Unix-only.;;;;;; Invariants enforced here:;;; * executable plus argv, never a shell command string;;;; * no /bin/sh, GNU timeout, env --chdir, pipelines, or POSIX signals;;;; * native spawn options supply child-local cwd and environment;;;; * Git -c arguments fix presentation without mutating ambient config;;;; * stdout and stderr are drained concurrently under byte ceilings;;;; * timeout and overflow terminate through portable process-kill!.;;;;;; Callers receive a typed execution record. They never inspect exceptions or;;; platform-specific exit conventions to decide whether a request timed out or;;; exceeded its output budget.(define-library (slate-cli git exec) (import (sigil core) (sigil io) (sigil process) (sigil async) (sigil channels) (sigil time) (sigil system grant)) (export git-exec git-spawn git-command-available? git-default-timeout git-output-limit) (begin (define (git-default-timeout) 10) (define (git-output-limit) 8388608) (define (git-command-available?) (command-exists? "git")) (define (bounded-read port limit proc) (guard (e (#t #{ text: "" error: #t exceeded: #f })) ;; Captured process ports support binary reads even though their public ;; handle is also textual. Reading bytevector chunks avoids one ;; scheduler-aware operation per character and preserves NUL-delimited ;; porcelain output exactly. Decode only after chunks are joined so a ;; UTF-8 sequence split at a chunk boundary remains valid. (let loop ((chunks '()) (bytes 0)) (let ((chunk (read-bytevector 65536 port))) (if (eof-object? chunk) (let ((joined (if (null? chunks) (bytevector) (apply bytevector-append (reverse chunks))))) #{ text: (utf8->string joined) error: #f exceeded: #f }) (let ((next (+ bytes (bytevector-length chunk)))) (if (> next limit) (begin (process-kill! proc) ;; A retained chunk can end inside a UTF-8 sequence. ;; Overflow is already a failed operation; do not let ;; decoding its partial output hide the named ceiling. #{ text: "" error: #f exceeded: #t }) (loop (cons chunk chunks) next)))))))) (define (git-environment) (list "LC_ALL=C" "LANG=C" "GIT_TERMINAL_PROMPT=0" "GIT_OPTIONAL_LOCKS=0")) (define (git-environment-with extra) (append (git-environment) extra)) (define (git-spawn grants cwd args) (grant-assert! grants 'exec "git") (grant-assert! grants 'fs-read cwd) (unless (git-command-available?) (error "git-unavailable" "Git is unavailable")) (apply process-spawn "git" cwd: cwd environment: (git-environment) (append (list "-c" "color.ui=false" "-c" "core.quotepath=false" "-c" "core.pager=cat") args))) (define (run-async cwd argv timeout-seconds output-limit environment) (let ((proc (apply process-spawn (car argv) cwd: cwd environment: (git-environment-with environment) (cdr argv)))) (unless (process? proc) (error "git-unavailable" "Git could not be started")) (let ((out-ch (make-channel 1)) (err-ch (make-channel 1)) (timed-out? (vector #f))) (go (channel-send out-ch (bounded-read (process-stdout proc) output-limit proc))) (go (channel-send err-ch (bounded-read (process-stderr proc) output-limit proc))) (go (let ((deadline (+ (current-second) timeout-seconds))) (let loop () (when (and (process-alive? proc) (< (current-second) deadline)) (sleep 0.01) (loop))) (when (process-alive? proc) (vector-set! timed-out? 0 #t) (process-kill! proc)))) (let ((out (channel-receive out-ch)) (err (channel-receive err-ch))) (let ((exit (process-wait proc))) #{ exit: exit stdout: (dict-ref out text: "") stderr: (dict-ref err text: "") timeout?: (vector-ref timed-out? 0) output-exceeded?: (or (dict-ref out exceeded: #f) (dict-ref err exceeded: #f)) read-error?: (or (dict-ref out error: #f) (dict-ref err error: #f)) }))))) (define (git-exec grants cwd args (keys: (timeout (git-default-timeout)) (output-limit (git-output-limit)) (environment '()))) (grant-assert! grants 'exec "git") (grant-assert! grants 'fs-read cwd) (unless (git-command-available?) (error "git-unavailable" "Git is unavailable")) (let ((argv (append (list "git" "-c" "color.ui=false" "-c" "core.quotepath=false" "-c" "core.pager=cat") args))) (if (in-async-context?) (run-async cwd argv timeout output-limit environment) (let ((box (vector #f))) (with-async (vector-set! box 0 (run-async cwd argv timeout output-limit environment))) (vector-ref box 0)))))))packages/slate-cli/src/slate-cli/git/log.sgladded
;;; (slate-cli git log) — bounded, node-owned history and commit metadata.(define-library (slate-cli git log) (import (sigil io) (sigil string) (sigil json) (slate-cli git exec) (slate-cli git status)) (export git-log git-show git-commit-oid?) (begin (define (git-commit-oid? value) (and (string? value) (member (string-length value) '(40 64)) (let loop ((i 0)) (or (= i (string-length value)) (and (string-contains? "0123456789abcdef" (substring value i (+ i 1))) (loop (+ i 1))))))) (define (run grants cwd argv) (let ((result (git-exec grants cwd argv output-limit: 262144))) (unless (and (= (dict-ref result exit: -1) 0) (not (dict-ref result timeout?: #f)) (not (dict-ref result output-exceeded?: #f)) (not (dict-ref result read-error?: #f))) (error "operation-failed" "Git history could not be read within its limits")) (dict-ref result stdout: ""))) ;; Fixed NUL fields, rather than punctuation delimiters in user text. (define (records fields body?) (if (null? fields) '() (let ((width (if body? 9 8))) (unless (>= (length fields) width) (error "operation-failed" "invalid Git history record")) (let ((oid (car fields))) (unless (git-commit-oid? oid) (error "operation-failed" "invalid Git commit identity")) (cons #{ oid: oid parents: (filter (lambda (s) (not (string=? s ""))) (string-split (list-ref fields 1) " ")) author: (list-ref fields 2) email: (list-ref fields 3) author-time: (or (string->number (list-ref fields 4)) 0) date: (list-ref fields 5) refs: (list-ref fields 6) subject: (list-ref fields 7) body: (if body? (list-ref fields 8) #f) } (records (drop-fields fields width) body?)))))) (define (drop-fields fields n) (if (= n 0) fields (drop-fields (cdr fields) (- n 1)))) (define format-fields "--format=%H%x00%P%x00%an%x00%ae%x00%at%x00%aI%x00%D%x00%s") (define (bounded-reply reply) (when (> (bytevector-length (string->utf8 (json-encode reply))) 262144) (error "operation-failed" "Git history page exceeds byte limit")) reply) (define (git-log grants args) (let* ((repo (dict-ref args repo: #f)) (limit (dict-ref args limit: 50)) (cursor (dict-ref args cursor: #f)) (offset (if (dict? cursor) (dict-ref cursor offset: -1) 0))) (unless (and (integer? limit) (> limit 0) (<= limit 100) (or (not cursor) (dict? cursor)) (integer? offset) (>= offset 0) (<= offset 10000000)) (error "operation-failed" "invalid history page bounds")) ;; Status revalidates grants and canonical repository identity on every ;; page. The cursor is stateless: no retained process or node session. (let* ((status (git-status grants #{ repo: repo recent-limit: 0 })) (snapshot (dict-ref status snapshot: #{})) (head (dict-ref snapshot head-oid: ""))) (when (and cursor (not (equal? head (dict-ref cursor head-oid: #f)))) (error "stale-snapshot" "HEAD changed; refresh history")) (let* ((commits (if (string=? head "") '() (records (git-split-nul (run grants (dict-ref repo worktree: "") (list "log" "-z" "--topo-order" "--no-show-signature" "--decorate=short" format-fields (string-append "--max-count=" (number->string (+ limit 1))) (string-append "--skip=" (number->string offset)) head "--"))) #f))) (more? (> (length commits) limit)) (page (if more? (reverse (cdr (reverse commits))) commits))) (bounded-reply #{ version: 1 repo: repo snapshot: snapshot branch: (dict-ref status branch: #{}) commits: page offset: offset complete: (not more?) next-cursor: (and more? #{ head-oid: head offset: (+ offset limit) }) }))))) (define (git-show grants args) (let ((repo (dict-ref args repo: #f)) (oid (dict-ref args oid: #f))) (unless (git-commit-oid? oid) (error "operation-failed" "commit inspection requires a full object ID")) (let* ((status (git-status grants #{ repo: repo recent-limit: 0 })) (commits (records (git-split-nul (run grants (dict-ref repo worktree: "") (list "log" "-z" "-1" "--no-show-signature" "--decorate=short" (string-append format-fields "%x00%b") oid "--"))) #t))) (unless (and (= (length commits) 1) (string=? oid (dict-ref (car commits) oid: ""))) (error "operation-failed" "object is not the requested commit")) (bounded-reply #{ version: 1 repo: repo snapshot: (dict-ref status snapshot: #{}) commit: (car commits) }))))))packages/slate-cli/src/slate-cli/git/mutation.sgladded
;;; Node-owned, snapshot-guarded file staging operations.(define-library (slate-cli git mutation) (import (sigil core) (sigil fs) (sigil path) (sigil crypto) (sigil string) (sigil time) (sigil random) (sigil system grant) (slate-cli git exec) (slate-cli git status)) (export git-stage-file git-unstage-file git-stage-hunk git-unstage-hunk git-discard-file git-discard-hunk git-mutation-before-publish-hook!) (begin ;; A deliberately narrow deterministic seam for the exact validation / ;; publication race fixture. Production leaves it false. (define *before-publish-hook* #f) (define (git-mutation-before-publish-hook! hook) (set! *before-publish-hook* hook)) (define (entry-by-id entries id) (find (lambda (entry) (string=? (dict-ref entry id: "") id)) entries)) (define (entry-paths entry) (let ((path (dict-ref entry path: #f)) (original (dict-ref entry original-path: #f))) (unless (string? path) (error "stale-snapshot" "status entry no longer has a path")) (if (and (string? original) (not (string=? original path))) (list original path) (list path)))) (define (run-mutation! grants worktree args . rest) (let ((result (if (null? rest) (git-exec grants worktree args) (git-exec grants worktree args environment: (car rest))))) (unless (= (dict-ref result exit: -1) 0) (error "operation-failed" "Git file mutation failed")) result)) (define (git-stdout! grants worktree args . rest) (string-trim (dict-ref (apply run-mutation! grants worktree args rest) stdout: ""))) (define (cleanup-file! path) (when (file-exists? path) (guard (e (#t #f)) (delete-file path)))) (define (worktree-index-path grants worktree) (git-stdout! grants worktree (list "rev-parse" "--path-format=absolute" "--git-path" "index"))) (define (candidate-index! grants worktree index-path paths stage? head new-entry?) (let* ((admin (path-dirname index-path)) (token (sha256 (string-append index-path (number->string (current-second)) (number->string (random))))) (real-index index-path) (candidate (path-join admin (string-append "slate-index-" token))) (patch (path-join admin (string-append "slate-index-" token ".patch"))) (environment (list (string-append "GIT_INDEX_FILE=" candidate)))) (guard (e (#t (cleanup-file! candidate) (cleanup-file! patch) (raise e))) (when (file-exists? real-index) (copy-file real-index candidate)) (let ((old-tree (git-stdout! grants worktree (list "write-tree")))) (if stage? (run-mutation! grants worktree (append (list "add" "-A" "--") paths) environment) (if (or new-entry? (string=? head "")) (run-mutation! grants worktree (append (list "rm" "-r" "--cached" "--ignore-unmatch" "--") paths) environment) (run-mutation! grants worktree (append (list "restore" "--staged" "--") paths) environment))) (let ((new-tree (git-stdout! grants worktree (list "write-tree") environment))) (let ((diff (run-mutation! grants worktree (append (list "diff-tree" "-p" "--binary" "--no-ext-diff" old-tree new-tree "--") paths)))) (write-file-string patch (dict-ref diff stdout: "")) #{ candidate: candidate patch: patch })))))) (define (publish-candidate! grants worktree candidate) ;; git apply owns index.lock and either publishes the complete cached ;; patch atomically or leaves the real index byte-identical on failure. (run-mutation! grants worktree (list "apply" "--cached" "--whitespace=nowarn" "--" (dict-ref candidate patch: "")))) (define (range-parts token) (let* ((body (substring token 1 (string-length token))) (parts (string-split body ","))) (cons (or (string->number (car parts)) 0) (if (null? (cdr parts)) 1 (or (string->number (cadr parts)) 0))))) (define (hunk-identity side file-id header) (let* ((parts (string-split header " ")) (old (and (>= (length parts) 3) (range-parts (list-ref parts 1)))) (new (and old (range-parts (list-ref parts 2))))) (and old new #{ file-id: file-id side: side old-start: (car old) old-lines: (cdr old) new-start: (car new) new-lines: (cdr new) context-hash: (sha256 header) }))) (define (hunk-identity=? expected actual) (and (dict? expected) (dict? actual) (equal? (dict-ref expected file-id: #f) (dict-ref actual file-id: #f)) (equal? (dict-ref expected side: #f) (dict-ref actual side: #f)) (= (dict-ref expected old-start: -1) (dict-ref actual old-start: -2)) (= (dict-ref expected old-lines: -1) (dict-ref actual old-lines: -2)) (= (dict-ref expected new-start: -1) (dict-ref actual new-start: -2)) (= (dict-ref expected new-lines: -1) (dict-ref actual new-lines: -2)) (equal? (dict-ref expected context-hash: #f) (dict-ref actual context-hash: #f)))) ;; Return the file prelude paired with every complete hunk. Git's patch is ;; reconstructed here; no rendered client text crosses this boundary. (define (patch-hunks text side file-id) (let loop ((lines (string-split text "\n")) (prelude '()) (header #f) (body '()) (out '())) (cond ((null? lines) (reverse (if header (cons (cons (hunk-identity side file-id header) (string-append (string-join (reverse prelude) "\n") "\n" (string-join (cons header (reverse body)) "\n") "\n")) out) out))) ((string-starts-with? (car lines) "@@ ") (let ((out (if header (cons (cons (hunk-identity side file-id header) (string-append (string-join (reverse prelude) "\n") "\n" (string-join (cons header (reverse body)) "\n") "\n")) out) out))) (loop (cdr lines) prelude (car lines) '() out))) (header (loop (cdr lines) prelude header (cons (car lines) body) out)) (else (loop (cdr lines) (cons (car lines) prelude) #f body out))))) (define (unsupported-partial? text) (or (string-contains? text "GIT binary patch") (string-contains? text "Binary files ") (string-contains? text "rename from ") (string-contains? text "rename to ") (string-contains? text "old mode ") (string-contains? text "new mode ") (string-contains? text "Subproject commit "))) (define (entry-partial-safe? entry stage?) (let* ((kind (dict-ref entry kind: "")) (modes (dict-ref entry modes: #{})) (head (dict-ref modes head: "")) (index (dict-ref modes index: "")) (worktree (dict-ref modes worktree: "")) (submodule (dict-ref entry submodule: "N..."))) (and (string=? kind "ordinary") (string=? submodule "N...") (if stage? (string=? index worktree) (string=? head index))))) (define (candidate-hunk! grants worktree index-path patch-text reverse?) (let* ((admin (path-dirname index-path)) (token (sha256 (string-append index-path (number->string (current-second)) (number->string (random)) patch-text))) (real-index index-path) (candidate (path-join admin (string-append "slate-index-" token))) (selected (path-join admin (string-append "slate-hunk-" token ".patch"))) (publish (path-join admin (string-append "slate-index-" token ".patch"))) (environment (list (string-append "GIT_INDEX_FILE=" candidate)))) (guard (e (#t (cleanup-file! candidate) (cleanup-file! selected) (cleanup-file! publish) (raise e))) (when (file-exists? real-index) (copy-file real-index candidate)) (write-file-string selected patch-text) (let ((old-tree (git-stdout! grants worktree (list "write-tree")))) (run-mutation! grants worktree (append (list "apply" "--cached" "--whitespace=nowarn") (if reverse? (list "--reverse") '()) (list "--" selected)) environment) (let* ((new-tree (git-stdout! grants worktree (list "write-tree") environment)) (delta (run-mutation! grants worktree (list "diff-tree" "-p" "--binary" "--no-ext-diff" old-tree new-tree)))) (write-file-string publish (dict-ref delta stdout: "")) (cleanup-file! selected) #{ candidate: candidate patch: publish }))))) (define (mutate-hunk grants args stage?) (let* ((repo (dict-ref args repo: #f)) (expected (dict-ref args expected-snapshot: #f)) (wanted (dict-ref args hunk-id: #f)) (worktree (and (dict? repo) (dict-ref repo worktree: #f))) (common (and (dict? repo) (dict-ref repo common-dir: #f))) (file-id (and (dict? wanted) (dict-ref wanted file-id: #f))) (side (if stage? "worktree" "staged"))) (unless (and (dict? expected) (dict? wanted) (string? file-id) (string? worktree) (string? common) (string=? (dict-ref wanted side: "") side)) (error "operation-failed" "invalid hunk mutation request")) (let ((index-path (worktree-index-path grants worktree))) (grant-assert! grants 'fs-write index-path) (let* ((before (git-status grants #{ repo: repo recent-limit: 0 })) (current (dict-ref before snapshot: #f))) (unless (git-snapshot=? expected current) (error "stale-snapshot" "hunk mutation snapshot is stale")) (let* ((entry (entry-by-id (dict-ref before entries: '()) file-id)) (path (and entry (dict-ref entry path: #f)))) (unless (and entry (string? path)) (error "stale-snapshot" "hunk file no longer exists")) (unless (entry-partial-safe? entry stage?) (error "operation-failed" "partial mutation is unsafe for this entry")) (let* ((argv (append (list "diff" "--no-ext-diff" "--no-color" "--no-textconv" "--unified=3") (if stage? '() (list "--cached")) (list "--" path))) (text (dict-ref (run-mutation! grants worktree argv) stdout: ""))) (when (unsupported-partial? text) (error "operation-failed" "partial mutation is unsafe for this entry")) (let ((matches (filter (lambda (pair) (hunk-identity=? wanted (car pair))) (patch-hunks text side file-id)))) (unless (= (length matches) 1) (error "stale-snapshot" "hunk identity is missing or ambiguous")) (let ((candidate (candidate-hunk! grants worktree index-path (cdr (car matches)) (not stage?)))) (guard (e (#t (cleanup-file! (dict-ref candidate candidate: "")) (cleanup-file! (dict-ref candidate patch: "")) (raise e))) (when *before-publish-hook* (*before-publish-hook*)) (let ((latest (dict-ref (git-status grants #{ repo: repo recent-limit: 0 }) snapshot: #f))) (unless (git-snapshot=? expected latest) (error "stale-snapshot" "hunk mutation snapshot is stale")) (publish-candidate! grants worktree candidate) (cleanup-file! (dict-ref candidate candidate: "")) (cleanup-file! (dict-ref candidate patch: "")) (git-status grants #{ repo: repo recent-limit: 20 }))))))))))) (define (mutate-file grants args stage?) (let* ((repo (dict-ref args repo: #f)) (expected (dict-ref args expected-snapshot: #f)) (entry-id (dict-ref args entry-id: #f)) (worktree (and (dict? repo) (dict-ref repo worktree: #f))) (common (and (dict? repo) (dict-ref repo common-dir: #f)))) (unless (and (dict? expected) (string? entry-id) (string? worktree) (string? common)) (error "operation-failed" "invalid file mutation request")) ;; fs-write checks a mutation target, not a directory handle. The ;; index (and its adjacent lock) is the file-level authority Git needs. (let ((index-path (worktree-index-path grants worktree))) (grant-assert! grants 'fs-write index-path) ;; Validate once before doing candidate work so stale requests cannot ;; select an entry from a newer snapshot. (let* ((before (git-status grants #{ repo: repo recent-limit: 0 })) (current (dict-ref before snapshot: #f))) (unless (git-snapshot=? expected current) (error "stale-snapshot" "file mutation snapshot is stale")) (let* ((entry (entry-by-id (dict-ref before entries: '()) entry-id)) (paths (and entry (entry-paths entry)))) (unless entry (error "stale-snapshot" "status entry no longer exists")) (let* ((modes (dict-ref entry modes: #{})) (new-entry? (string=? (dict-ref modes head: "") "000000")) (candidate (candidate-index! grants worktree index-path paths stage? (dict-ref current head-oid: "") new-entry?))) (guard (e (#t (cleanup-file! (dict-ref candidate candidate: "")) (cleanup-file! (dict-ref candidate patch: "")) (raise e))) (when *before-publish-hook* (*before-publish-hook*)) ;; Candidate construction may race worktree/index changes. ;; Revalidate only after it is complete and before Git's atomic ;; cached-patch publication. (let* ((latest (git-status grants #{ repo: repo recent-limit: 0 })) (latest-snapshot (dict-ref latest snapshot: #f))) (unless (git-snapshot=? expected latest-snapshot) (error "stale-snapshot" "file mutation snapshot is stale")) (publish-candidate! grants worktree candidate) (cleanup-file! (dict-ref candidate candidate: "")) (cleanup-file! (dict-ref candidate patch: "")) ;; Return authoritative fresh status so the client does not ;; create a post-success acknowledgement/refresh race. (git-status grants #{ repo: repo recent-limit: 20 }))))))))) (define (git-stage-file grants args) (mutate-file grants args #t)) (define (git-unstage-file grants args) (mutate-file grants args #f)) (define (git-stage-hunk grants args) (mutate-hunk grants args #t)) (define (git-unstage-hunk grants args) (mutate-hunk grants args #f)) ;; Discard is deliberately narrower than stage/unstage: a file row is ;; eligible only when it has worktree content to throw away (including an ;; untracked row), and a hunk is eligible only on the worktree side. ;; Rendered paths/patches are never accepted as authority. (define (discard-request grants args hunk?) (let* ((repo (dict-ref args repo: #f)) (expected (dict-ref args expected-snapshot: #f)) (worktree (and (dict? repo) (dict-ref repo worktree: #f))) (wanted (dict-ref args (if hunk? hunk-id: entry-id:) #f)) (entry-id (if hunk? (and (dict? wanted) (dict-ref wanted file-id: #f)) wanted))) (unless (and (dict? expected) (string? worktree) (string? entry-id)) (error "operation-failed" "invalid discard request")) (let* ((before (git-status grants #{ repo: repo recent-limit: 0 })) (current (dict-ref before snapshot: #f)) (entry (entry-by-id (dict-ref before entries: '()) entry-id)) (path (and entry (dict-ref entry path: #f)))) (unless (git-snapshot=? expected current) (error "stale-snapshot" "discard snapshot is stale")) (unless (and entry (string? path)) (error "stale-snapshot" "discard entry no longer exists")) (grant-assert! grants 'fs-write (path-join worktree path)) (cond (hunk? (unless (and (dict? wanted) (string=? (dict-ref wanted side: "") "worktree") (entry-partial-safe? entry #t)) (error "operation-failed" "partial discard is unsafe for this entry")) (let* ((text (dict-ref (run-mutation! grants worktree (list "diff" "--no-ext-diff" "--no-color" "--no-textconv" "--unified=3" "--" path)) stdout: "")) (matches (filter (lambda (pair) (hunk-identity=? wanted (car pair))) (patch-hunks text "worktree" entry-id)))) (when (unsupported-partial? text) (error "operation-failed" "partial discard is unsafe for this entry")) (unless (= (length matches) 1) (error "stale-snapshot" "discard hunk is missing or ambiguous")) (when *before-publish-hook* (*before-publish-hook*)) (unless (git-snapshot=? expected (dict-ref (git-status grants #{ repo: repo recent-limit: 0 }) snapshot: #f)) (error "stale-snapshot" "discard snapshot is stale")) (let* ((common (dict-ref repo common-dir: "")) (patch (path-join common (string-append "slate-discard-" (sha256 text) ".patch")))) (guard (e (#t (cleanup-file! patch) (raise e))) (write-file-string patch (cdr (car matches))) (run-mutation! grants worktree (list "apply" "--reverse" "--whitespace=nowarn" "--" patch)) (cleanup-file! patch))))) ((string=? (dict-ref entry kind: "") "untracked") (when *before-publish-hook* (*before-publish-hook*)) (unless (git-snapshot=? expected (dict-ref (git-status grants #{ repo: repo recent-limit: 0 }) snapshot: #f)) (error "stale-snapshot" "discard snapshot is stale")) (run-mutation! grants worktree (list "clean" "-f" "-d" "--" path))) ((not (string=? (dict-ref entry worktree-state: ".") ".")) (when *before-publish-hook* (*before-publish-hook*)) (unless (git-snapshot=? expected (dict-ref (git-status grants #{ repo: repo recent-limit: 0 }) snapshot: #f)) (error "stale-snapshot" "discard snapshot is stale")) (run-mutation! grants worktree (append (list "restore" "--worktree" "--") (entry-paths entry)))) (else (error "operation-failed" "status entry has no discardable worktree change"))) (git-status grants #{ repo: repo recent-limit: 20 })))) (define (git-discard-file grants args) (discard-request grants args #f)) (define (git-discard-hunk grants args) (discard-request grants args #t))))packages/slate-cli/src/slate-cli/git/status.sgladded
;;; (slate-cli git status) — node-owned Git repository discovery and status.;;;;;; This module owns the interpretation of Git output. The client never parses;;; porcelain text and never assembles generic proc/run calls. Both public;;; operations execute on the machine that owns the selected Slate root.;;;;;; `git-discover` validates the requested source path, asks Git for canonical;;; repository facts, then validates the discovered worktree and common-dir;;; again. It returns node-owned facts only; the provider/client attaches its;;; registered root name, per D3 in tasks/slate-git-status-plan.;;;;;; `git-status` validates that qualified repository afresh and parses;;; `--porcelain=v2 -z`. NUL delimiters are load-bearing: filenames may contain;;; spaces, tabs, newlines, non-ASCII text, leading dashes, and literal rename;;; arrows. Status replies contain inventory and summary metadata, never patch;;; bodies. All process execution, timeout, and byte bounds live in;;; (slate-cli git exec).(define-library (slate-cli git status) (import (sigil core) (sigil string) (sigil path) (sigil fs) (sigil crypto) (sigil system grant) (sigil system fs) (slate-cli git exec)) (export git-snapshot=? git-discover git-status git-split-nul git-status-records) (begin (define (git-snapshot=? a b) (and (dict? a) (dict? b) (equal? (dict-ref a head-oid: #f) (dict-ref b head-oid: #f)) (equal? (dict-ref a index-id: #f) (dict-ref b index-id: #f)) (equal? (dict-ref a worktree-id: #f) (dict-ref b worktree-id: #f)))) (define (required-string args key) (let ((v (dict-ref args key #f))) (if (string? v) v (error "operation-failed" "missing or non-string argument")))) (define (bounded-limit args) (let ((n (dict-ref args recent-limit: 20))) (if (and (integer? n) (>= n 0) (<= n 50)) n (error "operation-failed" "recent-limit must be between 0 and 50")))) (define (run-ok grants cwd args) (let ((r (git-exec grants cwd args))) (cond ((dict-ref r timeout?: #f) (error "operation-failed" "Git operation timed out")) ((dict-ref r output-exceeded?: #f) (error "operation-failed" "Git output exceeded limit")) ((dict-ref r read-error?: #f) (error "operation-failed" "Git output could not be read")) ((not (= (dict-ref r exit: -1) 0)) (error "not-a-repository" "not a Git repository")) (else (dict-ref r stdout: ""))))) (define (run-status-ok grants cwd args) (guard (e (#t (error "operation-failed" "Git status operation failed"))) (let ((result (git-exec grants cwd args))) (cond ((dict-ref result timeout?: #f) (error "operation-failed" "Git operation timed out")) ((dict-ref result output-exceeded?: #f) (error "operation-failed" "Git output exceeded limit")) ((dict-ref result read-error?: #f) (error "operation-failed" "Git output could not be read")) ((not (= (dict-ref result exit: -1) 0)) (error "operation-failed" "Git status operation failed")) (else (dict-ref result stdout: "")))))) (define (git-split-nul s) (let loop ((i 0) (start 0) (out '())) (cond ((>= i (string-length s)) (reverse (if (< start i) (cons (substring s start i) out) out))) ((= (char->integer (string-ref s i)) 0) (loop (+ i 1) (+ i 1) (cons (substring s start i) out))) (else (loop (+ i 1) start out))))) (define (discover-repository grants args) (let ((path (required-string args path:))) (grant-assert! grants 'fs-read path) ;; Repository discovery is on every status/snapshot hot path. Gather ;; the facts shared by bare and non-bare repositories in one process ;; instead of paying for four serial rev-parse invocations. (let* ((facts (string-split (string-trim (run-ok grants path (list "rev-parse" "--is-bare-repository" "--path-format=absolute" "--absolute-git-dir" "--git-common-dir" "--show-object-format"))) "\n")) (bare-text (if (pair? facts) (car facts) "false")) (bare? (string=? bare-text "true")) (git-dir (if (> (length facts) 1) (list-ref facts 1) "")) (worktree (if bare? git-dir (string-trim (run-ok grants path (list "rev-parse" "--path-format=absolute" "--show-toplevel"))))) (common (if (> (length facts) 2) (list-ref facts 2) "")) (format (if (> (length facts) 3) (list-ref facts 3) ""))) (unless (and (not (string=? worktree "")) (not (string=? git-dir "")) (not (string=? common "")) (not (string=? format ""))) (error "not-a-repository" "not a Git repository")) (grant-assert! grants 'fs-read worktree) (grant-assert! grants 'fs-read common) (grant-assert! grants 'fs-read git-dir) #{ version: 1 git-dir: git-dir node-repo: #{ worktree: worktree common-dir: common } bare: bare? object-format: format }))) (define (git-discover grants args) ;; Keep administrative paths node-owned. Public repository identity is ;; still the root-qualified worktree/common-dir pair. (let ((facts (discover-repository grants args))) #{ version: 1 node-repo: (dict-ref facts node-repo: #{}) bare: (dict-ref facts bare: #f) object-format: (dict-ref facts object-format: "") })) (define (field-at s n) (let ((parts (string-split s " "))) (if (< n (length parts)) (list-ref parts n) ""))) (define (tail-after-fields s n) (let loop ((i 0) (spaces 0)) (cond ((>= i (string-length s)) "") ((char=? (string-ref s i) #\space) (if (= (+ spaces 1) n) (substring s (+ i 1) (string-length s)) (loop (+ i 1) (+ spaces 1)))) (else (loop (+ i 1) spaces))))) (define (entry kind xy path original) #{ id: (sha256 (string-append kind "\x1f;" xy "\x1f;" path "\x1f;" (or original ""))) path: path original-path: original kind: kind index-state: (substring xy 0 1) worktree-state: (substring xy 1 2) }) (define (decorate-entry entry record) (let ((kind (dict-ref entry kind: ""))) (cond ((string=? kind "ordinary") (dict-set (dict-set (dict-set entry submodule: (field-at record 2)) modes: #{ head: (field-at record 3) index: (field-at record 4) worktree: (field-at record 5) }) binary: "unknown")) ((string=? kind "rename") (dict-set (dict-set (dict-set (dict-set entry submodule: (field-at record 2)) modes: #{ head: (field-at record 3) index: (field-at record 4) worktree: (field-at record 5) }) rename-score: (field-at record 8)) binary: "unknown")) ((string=? kind "conflict") (dict-set (dict-set (dict-set entry submodule: (field-at record 2)) conflict-stages: #{ stage-1: (field-at record 7) stage-2: (field-at record 8) stage-3: (field-at record 9) }) binary: "unknown")) (else (dict-set entry binary: "unknown"))))) (define (git-status-records raw) (let loop ((records (git-split-nul raw)) (out '())) (if (null? records) (reverse out) (let* ((record (car records)) (tag (if (> (string-length record) 0) (substring record 0 1) ""))) (cond ((string=? tag "1") (loop (cdr records) (cons (decorate-entry (entry "ordinary" (field-at record 1) (tail-after-fields record 8) #f) record) out))) ((string=? tag "2") (let ((original (and (pair? (cdr records)) (cadr records)))) (loop (if original (cddr records) (cdr records)) (cons (decorate-entry (entry "rename" (field-at record 1) (tail-after-fields record 9) original) record) out)))) ((string=? tag "u") (loop (cdr records) (cons (decorate-entry (entry "conflict" (field-at record 1) (tail-after-fields record 10) #f) record) out))) ((string=? tag "?") (loop (cdr records) (cons (decorate-entry (entry "untracked" "??" (substring record 2 (string-length record)) #f) record) out))) (else (loop (cdr records) out))))))) (define (branch-info headers) (let loop ((remaining headers) (branch #{ head: "" detached: #f unborn: #f upstream: #f ahead: 0 behind: 0 })) (if (null? remaining) branch (let ((header (car remaining))) (cond ((string-starts-with? header "# branch.head ") (let ((head (substring header 14 (string-length header)))) (loop (cdr remaining) (dict-set (dict-set branch head: head) detached: (string=? head "(detached)"))))) ((string-starts-with? header "# branch.oid ") (loop (cdr remaining) (dict-set branch unborn: (string=? (substring header 13 (string-length header)) "(initial)")))) ((string-starts-with? header "# branch.upstream ") (loop (cdr remaining) (dict-set branch upstream: (substring header 18 (string-length header))))) ((string-starts-with? header "# branch.ab ") (let ((parts (string-split header " "))) (loop (cdr remaining) (dict-set (dict-set branch ahead: (or (string->number (substring (list-ref parts 2) 1 (string-length (list-ref parts 2)))) 0)) behind: (or (string->number (substring (list-ref parts 3) 1 (string-length (list-ref parts 3)))) 0))))) (else (loop (cdr remaining) branch))))))) (define (branch-oid headers) (let loop ((remaining headers)) (cond ((null? remaining) "") ((string-starts-with? (car remaining) "# branch.oid ") (let ((oid (substring (car remaining) 13 (string-length (car remaining))))) (if (string=? oid "(initial)") "" oid))) (else (loop (cdr remaining)))))) (define (git-operation git-dir) (cond ((or (directory-exists? (path-join git-dir "rebase-merge")) (directory-exists? (path-join git-dir "rebase-apply"))) #{ kind: "rebase" }) ((file-exists? (path-join git-dir "MERGE_HEAD")) #{ kind: "merge" }) ((file-exists? (path-join git-dir "CHERRY_PICK_HEAD")) #{ kind: "cherry-pick" }) ((file-exists? (path-join git-dir "REVERT_HEAD")) #{ kind: "revert" }) ((file-exists? (path-join git-dir "BISECT_LOG")) #{ kind: "bisect" }) (else #f))) (define (split-unit-separator record) (string-split record "\x1f;")) (define (commit-record record) (let ((parts (split-unit-separator record))) #{ oid: (if (pair? parts) (car parts) "") parents: (if (> (length parts) 1) (string-split (list-ref parts 1) " ") '()) author-time: (if (> (length parts) 2) (or (string->number (list-ref parts 2)) 0) 0) subject: (if (> (length parts) 3) (list-ref parts 3) "") })) (define (scope-entry-id entry repo head index-id worktree-id) (dict-set entry id: (sha256 (string-append (dict-ref repo root: "") "\x1f;" (dict-ref repo worktree: "") "\x1f;" (dict-ref repo common-dir: "") "\x1f;" head "\x1f;" index-id "\x1f;" worktree-id "\x1f;" (dict-ref entry id: ""))))) ;; Porcelain state alone cannot distinguish two successive modifications ;; of the same path. Include Git-computed worktree object identities so a ;; mutation snapshot detects content races without transporting patches. (define (zip-identities paths hashes) (if (or (null? paths) (null? hashes)) '() (cons (cons (car paths) (car hashes)) (zip-identities (cdr paths) (cdr hashes))))) (define (batched-worktree-identities grants worktree entries) (let* ((paths (map (lambda (entry) (dict-ref entry path: "")) (filter (lambda (entry) (not (string=? (dict-ref entry kind: "") "untracked"))) entries))) (result (and (pair? paths) (git-exec grants worktree (append (list "hash-object" "--no-filters" "--") paths)))) (hashes (if (and result (= (dict-ref result exit: -1) 0)) (filter (lambda (line) (not (string=? line ""))) (string-split (dict-ref result stdout: "") "\n")) '()))) (and (= (length paths) (length hashes)) (zip-identities paths hashes)))) (define (worktree-fingerprint grants worktree raw entries) (let ((batched (batched-worktree-identities grants worktree entries))) (sha256 (fold-left (lambda (out entry) (let* ((path (dict-ref entry path: "")) (untracked? (string=? (dict-ref entry kind: "") "untracked")) (stat (and untracked? (guard (e (#t #f)) (fs-stat grants (path-join worktree path))))) (known (and batched (assoc path batched))) (result (and (not untracked?) (not known) (git-exec grants worktree (list "hash-object" "--no-filters" "--" path)))) (identity (cond (stat (string-append "stat:" (number->string (dict-ref stat size: 0)) ":" (number->string (dict-ref stat mtime: 0)) ":" (number->string (dict-ref stat mode: 0)))) (known (cdr known)) ((and result (= (dict-ref result exit: -1) 0)) (string-trim (dict-ref result stdout: ""))) (else "missing")))) (string-append out "\x1f;" path "\x1f;" identity))) raw entries)))) (define (git-status grants args) (let* ((repo (dict-ref args repo: #f)) (worktree (and (dict? repo) (dict-ref repo worktree: #f))) (common (and (dict? repo) (dict-ref repo common-dir: #f))) (limit (bounded-limit args))) (unless (and (string? worktree) (string? common)) (error "operation-failed" "invalid repository identity")) (grant-assert! grants 'fs-read worktree) (grant-assert! grants 'fs-read common) (let* ((facts (discover-repository grants #{ path: worktree })) (git-dir (dict-ref facts git-dir: "")) (discovered (dict-ref facts node-repo: #{})) (canonical-worktree (dict-ref discovered worktree: #f)) (canonical-common (dict-ref discovered common-dir: #f))) (unless (and (string=? worktree canonical-worktree) (string=? common canonical-common)) (error "operation-failed" "repository identity does not match canonical Git metadata")) (let* ((raw (run-status-ok grants worktree (list "status" "--porcelain=v2" "-z" "--branch" "--untracked-files=all"))) (index-raw (run-status-ok grants worktree (list "ls-files" "--stage" "-z"))) (records (git-split-nul raw)) (headers (filter (lambda (record) (string-starts-with? record "# ")) records)) ;; porcelain v2 already reports HEAD, including the unborn ;; marker, so a separate rev-parse adds latency but no data. (head (branch-oid headers)) (parsed-entries (git-status-records raw)) (index-id (sha256 index-raw)) (worktree-id (worktree-fingerprint grants worktree raw parsed-entries)) (entries (map (lambda (entry) (scope-entry-id entry repo head index-id worktree-id)) parsed-entries)) (log-result (and (> limit 0) (git-exec grants worktree (list "log" "-z" (string-append "-n" (number->string limit)) "--format=%H%x1f%P%x1f%at%x1f%s")))) (log-text (if (and log-result (= (dict-ref log-result exit: -1) 0)) (dict-ref log-result stdout: "") "")) (commits (map commit-record (git-split-nul log-text)))) #{ version: 1 repo: repo snapshot: #{ head-oid: head index-id: index-id worktree-id: worktree-id } branch: (branch-info headers) operation: (git-operation git-dir) entries: entries recent-commits: commits capabilities: #{ stage-file: #t unstage-file: #t stage-hunk: #t unstage-hunk: #t } }))))))packages/slate-cli/src/slate-cli/http/stream.sgldeleted
;;; Compatibility import for sigil-http's incremental response API.;;;;;; Keeping this small module preserves Slate's existing import boundary while;;; allowing it to disappear in a later package-upgrade-only cleanup.(define-library (slate-cli http stream) (import (sigil http client)) (export http-stream-response))packages/slate-cli/src/slate-cli/httpfetch.sglmodified
;; this covers other https callers, e.g. cli-latest.) (if (and (https-url? url) (not (tls-verification-available?))) #f (let ((r (http-fetch-bytes method url headers: (forward-request-headers req-headers) body: body timeout: timeout))) (and r (dict-set r headers: (strip-response-hops (fetch-headers r)))))))) ;; Transport failures are an expected offline condition for the ;; proxy. Keep exceptions from escaping the connection handler ;; (which would otherwise look like an empty HTTP response), and ;; retain the diagnostic in the supervised service output. (guard (e (#t (display "slate proxy: upstream fetch failed: " (current-error-port)) (write e (current-error-port)) (newline (current-error-port)) #f)) (let ((r (http-fetch-bytes method url headers: (forward-request-headers req-headers) body: body timeout: timeout))) (and r (dict-set r headers: (strip-response-hops (fetch-headers r))))))))) ;; ---- request header shaping ----------------------------------- ;; Forward the caller's headers minus hop-by-hop / host / framing, andpackages/slate-cli/src/slate-cli/listen.sglmodified
;; Cap the unassembled WS-frame buffer (finding #3). P0/P1 traffic is ;; control frames + small echoes; the credited session/transfer lanes ;; (P3) will raise this behind auth. 1 MiB is generous pre-auth. (define max-frame-bytes 1048576) ;; v1 bulk remains one WebSocket message. Bound it above the live 2 MiB ;; round-trip while continuing to reject an unbounded declared frame. (define max-frame-bytes 4194304) (define frame-read-chunk-bytes 65536) ;; ---------------------------------------------------------------- ;; Listener state (cond ((not client) (loop)) (else ;; Interactive terminal frames must not wait for an ACK ;; before the next small response can leave this socket. (socket-set-tcp-nodelay! client #t) (socket-set-non-blocking! client #t) ;; Each connection is fully isolated: a raise in one ;; connection's goroutine must never tear down the ;; nothing at all. The Host header is echoed because it is the ;; whole evidence: it names what the request claimed to be talking ;; to. ((not (host-allowed? headers port)) ((not (let ((allowed (dict-ref handlers request-allowed?: #f))) (if allowed (allowed daemon headers port) (host-allowed? headers port)))) (log-warn* "node/http-refused" mod: "node" status: 421 reason: "host-not-allowed" host-header: (log-bounded-token (header-ref headers "host" "")) ;; an alias for the existing harness + older clients during the ;; pivot. Both are reserved: never proxied upstream. ((and (websocket-upgrade? headers) (or (string=? path "/node/ws") (string=? path "/ws"))) (let ((path-ok (dict-ref handlers websocket-path?: #f))) (if path-ok (path-ok daemon path) (or (string=? path "/node/ws") (string=? path "/ws"))))) (upgrade-and-run! client buffer daemon handlers)) ;; Everything else is a plain HTTP request (pairing pages). (else ((or (not clen) (<= clen 0)) "") ((> clen max-head-bytes) "") ; pairing bodies are tiny; refuse huge (else (let ((have (body-after-headers buffer))) ;; Content-Length counts UTF-8 bytes, not Scheme characters. ;; Keep partial code points intact until the body is complete. (let ((have (string->utf8 (body-after-headers buffer)))) (let loop ((body have)) (cond ((>= (string-length body) clen) (substring body 0 clen)) ((>= (bytevector-length body) clen) (utf8->string (bytevector-copy body 0 clen))) (else (await-readable client) (let ((chunk (socket-read client))) (let ((chunk (socket-read-bytevector client (- clen (bytevector-length body))))) (cond ((or (not chunk) (eof-object? chunk)) body) ((string=? chunk "") (loop body)) (else (loop (string-append body chunk))))))))))))) ((or (not chunk) (eof-object? chunk)) (utf8->string body)) ((= (bytevector-length chunk) 0) (loop body)) (else (loop (bytevector-append body chunk))))))))))))) ;; The substring of the accumulated head-read that follows the ;; CRLFCRLF header terminator (the start of the body, if any). ((not frame) ;; Need more bytes. (await-readable client) (let ((chunk (socket-read-bytevector client))) (let ((chunk ;; The native ABI accepts an optional maximum. Larger ;; chunks keep accumulation of a bounded multi-megabyte ;; frame from repeatedly copying it in 4 KiB steps. (apply socket-read-bytevector (list client frame-read-chunk-bytes)))) (cond ((or (not chunk) (eof-object? chunk)) (safe-close client)) ((zero? (bytevector-length chunk)) (loop buf)) ;; Host gate (anti-DNS-rebinding, design §1.6) ;; ---------------------------------------------------------------- ;;; The Host header must literally name the loopback authority on ;;; the daemon's port. Anything else (a rebound `evil.example`, a ;;; The Host header must literally name the loopback authority. Native ;;; sigil-websocket currently omits a non-default port from Host, so the ;;; exact bound literals are accepted both bare and with this listener's ;;; port. Anything else (a rebound `evil.example`, a ;;; public name) is a misdirected request. ;; Compared case-insensitively (hostnames are case-insensitive; ;; finding #10). We accept only the authorities we actually bind — (let ((host (string-downcase (header-ref headers "host" ""))) (ps (number->string port))) (or (string=? host (string-append "127.0.0.1:" ps)) (string=? host (string-append "localhost:" ps))))) (string=? host (string-append "localhost:" ps)) (string=? host "127.0.0.1") (string=? host "localhost")))) ;; ---------------------------------------------------------------- ;; Plain HTTP responsespackages/slate-cli/src/slate-cli/logging.sglmodified
;;; Optional because `test-logging-contract.sgl` pins the 3-positional ;;; shape and it must keep passing; the control node always supplies it. (define (stream-child-argv exe ws-url client ticket log-file log-level . sock-path) . extra-positionals) (append (list exe "node" "_stream" ws-url client ticket) (if (and (pair? sock-path) (car sock-path)) (list (car sock-path)) '()) (filter (lambda (value) value) extra-positionals) (if log-file (list "--log" log-file) '()) (if (and log-file log-level) (list "--log-level" log-level) '())))packages/slate-cli/src/slate-cli/main.sglmodified
(sigil socket) (sigil process) (sigil async) (only (sigil math) min) (sigil websocket) (sigil json) (sigil array) (sigil log) (sigil irc message) (slate-cli logging) (slate-cli config) (slate-cli origins) (slate-cli peercred) (slate-cli wire) (slate-cli dispatch) (slate-cli resource transfer connection) (slate-cli sessions) (slate-cli terminal discovery) (slate-cli terminal owner) (slate-cli listen) (slate-cli cache) (slate-cli proxy) (slate-cli launch) (slate-cli persistent) (slate-cli node-posture) (slate-cli tlsguard) (slate-cli enclave-node-init) (slate-cli enclave-node-session) (slate-cli agent service) (slate-cli agent service-client) (slate-cli agent ipc) (slate-cli node dial) (slate-cli node direct) (slate-cli node pairing) (slate-cli node pairing-management) (slate-cli node pairing-carrier) (slate-cli node pairing-client) (slate-cli node gateway) (slate-cli agent cli) (slate-cli agent host)) (export main) (export main http-route-kind) (begin (define port-candidates (list 32640 32641 32642 32643)) (define loopback-host "127.0.0.1") (define default-direct-port 32644) ;; THE ARGUMENT LIST IS PARSED EXACTLY ONCE, HERE, AND NOTHING BELOW ;; RE-READS `(command-line)` FOR THE LOGGING FLAGS. (string=? (car args) "-h")) (print-help)) ((string=? (car args) "--version") (display "slate 0.1.0\n")) (display (string-append "slate " local-node-version "\n"))) (else (log-warn "unknown subcommand" mod: "main" arg: (car args)) (display (string-append "slate: unknown subcommand: " (car args) "\n")) (define (log-startup-facts! args) (log-info* "slate starting" mod: "main" version: "0.1.0" version: local-node-version pid: (process-id) command: (safe-command-summary args) state-dir: (config-state-dir (load-config)) (or (dict-ref opts key #f) (begin (display (string-append "slate node init: " label " is required\n")) (display "usage: slate node init --enclave <https-url> --account <nick> --name <node-name>\n") (display "usage: slate node init --enclave <https-url> --account <nick> [--name <node-name>]\n") (exit 2)))) ;; `opts` is the parsed logging configuration from `main`, threaded here (cond ;; Identity setup stays outside with-async: native suspension through ;; this caller boundary restarts the whole command (folio t-899b78). ;; `node run` installs its scheduler only around the steady-state poll ;; loop, where remote PTY streams need it. ;; `node run` owns one scheduler for the loopback listener and its ;; optional Enclave control subsystem. ((and (pair? args) (string=? (car args) "init")) (run-node-init! (cdr args))) ((and (pair? args) (string=? (car args) "run")) (run-node-remote! opts)) (run-node-companion! (cdr args) opts)) ((and (pair? args) (string=? (car args) "pairing")) (run-node-pairing! (cdr args))) ((and (pair? args) (string=? (car args) "install")) (run-persistent-install! (cdr args))) ((and (pair? args) (member (car args) '("start" "stop" "status" "logs" "restart" "uninstall" "app"))) (run-persistent-command! (car args) (cdr args))) ;; 3 args is the pre-R1 shape and still works. A FOURTH is the ;; holder's control-socket path: the seam over which a parked holder ;; is handed a new ticket, so it can dial a replacement /stream for ;; sessions it is already holding. Optional so nothing that spawns ;; the old way breaks. ((and (= (length args) 4) (string=? (car args) "_terminal-owner")) (run-terminal-owner! (cadr args) (caddr args) (or (string->number (list-ref args 3)) default-session-ttl))) ((and (pair? args) (string=? (car args) "_stream") (or (= (length (cdr args)) 3) (= (length (cdr args)) 4))) (member (length (cdr args)) '(3 4 5))) (run-node-stream-worker! (cadr args) (caddr args) (list-ref args 3) (if (= (length (cdr args)) 4) (list-ref args 4) #f))) (and (>= (length (cdr args)) 4) (list-ref args 4))) (if (= (length (cdr args)) 5) (guard (e (#t '())) (json-decode (list-ref args 5))) '()))) (else (display "usage: slate node init --enclave <https-url> --account <nick> --name <node-name>\n slate node run\n") (display "usage: slate node init --enclave <https-url> --account <nick> [--name <node-name>]\n slate node run [--direct-host <vpn-address>] [--direct-port <port>]\n slate node pairing list|revoke <peer>|rotate\n slate node install --persistent [--name <node-name>]\n slate node start|stop|status [--json]|logs|restart|uninstall\n slate node app <staging|production|https-url>\n") (exit 2)))) (define (run-node-pairing! args) (let ((state-dir (config-state-dir (load-config)))) (guard (e (#t (display (string-append "slate node pairing: " (guard (ignored (#t "operation failed")) (if (and (pair? (exception-irritants e)) (string? (car (exception-irritants e)))) (car (exception-irritants e)) "operation failed")) "\n")) (exit 1))) (cond ((or (null? args) (string=? (car args) "list")) (display (json-encode (pairing-management-dispatch state-dir "pairing/list" #{}))) (newline)) ((and (string=? (car args) "revoke") (pair? (cdr args))) (display (json-encode (pairing-management-dispatch state-dir "pairing/revoke" #{ peer: (cadr args) }))) (newline)) ((string=? (car args) "rotate") (display (json-encode (pairing-management-dispatch state-dir "pairing/rotate" #{}))) (newline)) (else (error "usage: slate node pairing list|revoke <peer>|rotate")))))) ;; How many stream children this process has spawned. A COUNTER, NOT A ;; LIST OF HANDLES, and the distinction is the whole point. ;; (define (note-stream-child!) (set! *stream-children-spawned* (+ *stream-children-spawned* 1))) ;; Resource transfers use short-lived workers, separate from durable ;; session holders. Keeping binary sockets out of the control process ;; prevents a slow or closing transfer from blocking later node requests. (define *resource-workers* '()) (define (reap-resource-workers!) (set! *resource-workers* (filter (lambda (worker) (if (process-alive? (car worker)) #t (begin (process-wait (car worker)) (guard (e (#t #f)) (delete-file (cdr worker))) #f))) *resource-workers*))) (define (spawn-resource! record opts config client request-id ticket metadata) (reap-resource-workers!) (if (>= (length *resource-workers*) 8) #{ code: "busy" message: "the node is busy transferring files" } (guard (e (#t #{ code: "unavailable" message: "the node could not start a file transfer" })) (let* ((dir (path-join (config-state-dir config) "resources")) (sock (mint-sock-path dir)) (log-file (and (log-opts-file opts) (string-append (log-opts-file opts) ".resource-" request-id)))) (ensure-directory dir) (unless (= 0 (process-run "chmod" "700" dir)) (error "cannot secure resource directory")) (let* ((argv (stream-child-argv (car (command-line)) (node-init-stream-websocket-url record) client ticket log-file (log-opts-level opts) sock (json-encode (dict-set metadata resource-only?: #t)))) (proc (apply process-spawn (append argv (list die-with-parent: #t in: "/dev/null" out: "/dev/null" err: (if log-file (string-append log-file ".stderr") "/dev/null")))))) (unless proc (error "cannot start resource worker")) (set! *resource-workers* (cons (cons proc sock) *resource-workers*)) #t))))) ;; ================================================================ ;; THE ROUTER (R1b) ;; ;;; Remember the sid a `stream/open` named. `sid` #f means "no sid key at ;;; all" => a NEW session; nothing is stored and the ticket will spawn. ;;; `resource:` routes a `resource/open` ticket to a file-transfer worker. (define (pending-note! sender request-id sid) (let ((now (current-second))) (pending-sweep! now) ;;; Spawn a holder for a NEW session. Returns the process, or a refusal ;;; dict. (define (spawn-holder! record opts config client request-id ticket) (define (spawn-holder! record opts config client request-id ticket metadata) ;; Reap BEFORE testing the cap, so an exited holder never occupies a ;; slot. Otherwise a node that has churned eight terminals refuses the ;; ninth while holding eight corpses. (else (let* ((exe (car (command-line))) (sock (mint-sock-path dir)) ;; `open-log-append!` emulates append with read/truncate/write ;; and is not safe for concurrent processes sharing a path. ;; A holder therefore gets a request-scoped file rather than ;; racing its parent and erasing the very startup evidence ;; needed when its relay handshake fails. (child-log (and (log-opts-file opts) (string-append (log-opts-file opts) ".holder-" request-id))) (argv (stream-child-argv exe (dict-ref record websocket-url: "") (node-init-stream-websocket-url record) client ticket (log-opts-file opts) child-log (log-opts-level opts) sock)) sock (json-encode metadata))) ;; GUARDED, AND THE #f CASE IS HANDLED SEPARATELY. ;; `process-spawn` returns #f when the process cannot be ;; started (fork failure under RLIMIT_NPROC or memory (guard (e (#t #f)) (socket-close s)) ok)))) (define (run-node-remote! opts) (let* ((configured-grants (getenv "SLATE_NODE_GRANTS")) (config (make-config dev-mode?: (env-flag? "SLATE_DEV") ;; Until the file-backed config loader lands, allow a ;; headless node operator (and the real-system gate) to ;; narrow the owner grant set explicitly. Never widens ;; unless the operator supplies the capability specs. silent-pair-grants: (if (and configured-grants (> (string-length configured-grants) 0)) (string-split configured-grants ",") (silent-pair-grants-for-host loopback-host)))) (define (run-node-remote! opts config direct daemon) (let* ( (target (enclave-node-path config)) (record (node-init-load target)) (direct-identity (and record (or (pairing-load-identity (config-state-dir config)) (let ((identity (pairing-make-identity 1))) (pairing-save-identity! (config-state-dir config) identity) identity)))) (failure #f)) (unless record (log-fatal "no Enclave identity" mod: "node" target: target) (display "slate node run: no Enclave identity; run 'slate node init' first\n") (exit 2)) (when direct-identity (vector-set! (dict-ref daemon pairing-identity: #()) 0 direct-identity)) ;; No credential is a normal pure-local companion posture. The caller ;; keeps the loopback node alive and checks again later, so enrollment ;; can be completed by a separate CLI invocation without restarting it. (when record (vector-set! (dict-ref daemon node-identity: #()) 0 (dict-ref record transport-nick: (dict-ref record node-name: ""))) (vector-set! (dict-ref daemon node-label: #()) 0 (dict-ref record node-name: "")) (vector-set! (dict-ref daemon node-bot-id: #()) 0 (dict-ref record bot-id: "")) ;; The loopback gateway is allowed to name only a target identity and ;; correlation id. Enclave resolves ownership and endpoint material; ;; the browser never supplies either an endpoint or a ticket. (vector-set! (dict-ref daemon gateway-open: #()) 0 (lambda (target request-id carrier sid) (if (not (node-session-done?)) (gateway-handle-paired-open! daemon target request-id carrier sid) (let* ((registry (dict-ref daemon gateway: #f)) (relay (gateway-mark-relay! registry carrier 'standby)) ;; Enclave preserves command order on this control ;; connection. Ask for direct rendezvous first so its ;; ticket/failure is observed before the already-armed ;; relay response can bind the carrier. (direct-sent (node-session-send-command! "DIRECT-OPEN" target request-id)) (relay-sent (and relay (node-session-send-command! "ENCLAVE-RPC" target request-id "stream/open" (json-encode #{ id: carrier args: (if sid #{ sid: sid } #{}) }))))) (and relay-sent direct-sent))))) (log-info* "node run starting" mod: "node" node-name: (dict-ref record node-name: "") (let ((removed (holder-sock-sweep! dir))) (log-info* "holder socket directory ready" mod: "node" dir: dir stale-removed: removed)))) ;; Resume durable managed work even before a browser opens a stream. stale-removed: removed)) (let ((path (path-join dir "node.sock"))) (when (enclave-stream-listen! path config (dict-ref daemon sessions: #f)) (vector-set! (dict-ref daemon session-directory: #f) 0 (lambda (principal mine) (terminal-discover dir path principal mine))))))) ;; Resume durable managed work before the first browser attachment. (agent-ipc-close! (agent-service-connect! config)) ;; [next-at delay]. Failed Enclave dials must not become an almost ;; continuous tax on the direct gateway when the hub is unreachable. ;; Back off independently; a successful registration resets it. (let ((reconnect-at (vector 0 5))) (node-session-resume! record (lambda (ready) (set! failure #f) (vector-set! reconnect-at 1 5) ;; Identity enrollment is independent of listener availability: a ;; relay-only node may initiate pairing and must still prove the ;; public descriptor Enclave places in the rendezvous ticket. (when direct-identity (node-session-send-command! "NODE-IDENTITY" (dict-ref record bot-id: "") (dict-ref direct-identity key-id: "") (number->string (dict-ref direct-identity generation: 0)) (dict-ref direct-identity public-key: ""))) (when direct (vector-set! (dict-ref direct node-cell: #()) 0 (dict-ref record transport-nick: "")) ;; Advertise only after the separate socket has successfully ;; bound and the durable node identity has authenticated. (node-session-send-command! "NODE-ENDPOINT" (dict-ref direct endpoint: "") "60" (dict-ref record bot-id: "") (dict-ref direct-identity key-id: "") (number->string (dict-ref direct-identity generation: 0)) (dict-ref direct-identity public-key: "")) (vector-set! (dict-ref direct advertised-cell: #()) 0 (current-second))) (log-info* "enclave session ready" mod: "node" node-name: (dict-ref ready node-name: "node")) (display (string-append "Slate node connected to Enclave as " (dict-ref ready node-name: "node") "\n"))) (lambda (msg) (let ((response (let* ((direct-ticket (direct-ticket-from-message msg)) (ticket-store (dict-ref daemon direct-tickets: #f)) (_stored (and direct-ticket (direct-ticket-store-put! ticket-store direct-ticket))) (_gateway (and direct-ticket (eq? (dict-ref direct-ticket role: #f) 'caller) (let ((cell (dict-ref daemon direct-ticket-pending: #()))) (and (> (vector-length cell) 0) (begin (vector-set! cell 0 direct-ticket) #t))))) (gateway-relay? (and (gateway-relay-response? daemon msg) (let ((cell (dict-ref daemon relay-ticket-pending: #()))) (and (> (vector-length cell) 0) (begin (vector-set! cell 0 msg) (log-info* "relay ticket queued" mod: "node") #t))))) (_direct-failure (gateway-handle-direct-failure! daemon msg)) (response (and (not gateway-relay?) (or (remote-handle-ticket msg (lambda (client request-id ticket) (let ((sid (pending-take! client request-id))) (if sid (lambda (client request-id ticket metadata) (let ((sid (pending-take! client request-id)) (pairing-meta #{ request-id: request-id caller-transport-nick: client acceptor-transport-nick: (dict-ref record transport-nick: (dict-ref record node-name: "")) descriptors: metadata })) (cond ((eq? sid resource:) (spawn-resource! record opts config client request-id ticket pairing-meta)) (sid ;; ---- REATTACH: route, never spawn ---- ;; ;; The client named a session. Falling back ;; the duplicate-session defect and is worse ;; than an error they can see. So a failure ;; here refuses. (let ((owner (holder-owner-of sid))) (cond (let ((local-hub (dict-ref daemon sessions: #f))) (if (or (terminal-owner-holds? config sid) (and local-hub (hub-holds-sid? local-hub sid))) ;; Direct sessions live in this daemon. ;; Attach the relay adapter to the same ;; hub; never migrate it into a child. (enclave-stream-start-on-hub! (node-init-stream-websocket-url record) client ticket config local-hub pairing-meta) (let ((owner (holder-owner-of sid))) (cond ((dict? owner) owner) ; refusal, as-is ((holder-deliver-ticket! owner (dict-ref record websocket-url: "") owner (node-init-stream-websocket-url record) client ticket) (log-info* "ticket routed to the owning holder" mod: "node" pid: (holder-pid owner)) (reap-exited-holders!) #{ code: "dead" message: "the holder for this session is gone" }))) message: "the holder for this session is gone" })))))) (else ;; ---- NEW SESSION: spawn a holder ---- (spawn-holder! record opts config client request-id ticket))))) (remote-handle-rpc config msg pending-note!)))) client request-id ticket pairing-meta)))))) (remote-handle-rpc config msg pending-note!))))) ;; DEBUG, not warn, and the level is the whole point: once the ;; session reaches phase `done` EVERY inbound IRC message lands ;; here, including numerics and notices that legitimately need no (apply node-session-send-command! response)))) (lambda (reason) (log-error "enclave session failed" mod: "node" reason: reason) (set! failure reason))) ;; Keep the control plane on its proven synchronous path. Each terminal ;; stream runs in a short-lived child process so its blocking websocket ;; and async PTY pumps cannot starve control RPCs in this native runtime. ;; ;; NO PERIODIC HEARTBEAT IS EMITTED HERE, AND THAT IS DELIBERATE. ;; This loop does not turn on a timer. Because `run-node-remote!` is ;; not inside `with-async`, `(current-scheduler)` is #f, and ;; sigil-websocket only switches the socket to non-blocking and only ;; awaits readability `(when (current-scheduler) ...)`. So ;; `node-session-poll!` BLOCKS IN THE KERNEL inside `ws-receive` until ;; a frame arrives or the socket closes, and this loop ticks once per ;; inbound message rather than every 10ms. ;; ;; A heartbeat here would therefore be inbound-traffic-driven: it could ;; only print while messages were arriving, and would fall silent in ;; exactly the wedge it was meant to diagnose (folio t-c57a8c: shepherd ;; healthy, socket ESTABLISHED, parent in `wait_woken` — which is the ;; signature of this blocked read, not of polling). The instrument that ;; DOES work is the paired receive-enter/receive-exit trace in ;; (slate-cli enclave-node-session): an unmatched enter as the last ;; line in the log names the blocked call positively, where silence ;; could only hint at it. ;; ;; Making this loop tick on a timer means a non-blocking or ;; timeout-bounded control read. That is a behaviour change to the path ;; the comment above calls proven, and it belongs to R1. (set! failure reason) (let ((delay (vector-ref reconnect-at 1))) (vector-set! reconnect-at 0 (+ (current-second) delay)) (vector-set! reconnect-at 1 (min 60 (* delay 2)))Showing the first 500 of 2020 diff lines for this file. This diff is INCOMPLETE; read the file or clone the repository for the rest.
packages/slate-cli/src/slate-cli/node-posture.sgladded
;;; (slate-cli node-posture) — declared role and capability posture.;;;;;; This is build/runtime posture, not an authorization result. Effective;;; per-origin grants remain on the authenticated sys/info surface. Keeping;;; the remote transport manager explicitly unavailable makes the local-node;;; boundary machine-readable without introducing transport ownership here.(define-library (slate-cli node-posture) (import (sigil core) (sigil meta)) (export local-node-version local-node-identity local-node-capability-posture local-node-upgrade-posture local-node-capability-names local-node-allows-verb?) (begin ;; Baked by sigil-build from slate-cli's package version. Keep runtime ;; posture and release metadata on the same manifest authority. (define local-node-version (or (app-version) "unknown")) (define (local-node-identity) #{ role: "local-node" scope: "local-device" }) (define (local-node-capability-posture) #{ resource: #{ available: #t operations: #[ "fs" "proc" "sys" "git" ] } session: #{ available: #t operations: #[ "terminal" ] } agent: #{ available: #t operations: #[ "agent" ] } renderer: #{ available: #t same-origin: #t } upgrade: #{ available: #t operations: #[ "check" ] apply-available: #f } remote-transport-manager: #{ available: #f reason: "not-implemented" } }) ;; Read-only upgrade posture. Checking is safe and already has a dedicated ;; same-origin endpoint; applying and rollback remain closed until their ;; staging, verification, restart, and recovery contracts are implemented. (define (local-node-upgrade-posture platform environment installed latest) #{ current-version: local-node-version available-version: latest state: (cond ((not (string? latest)) "not-checked") ((string=? latest local-node-version) "up-to-date") (else "upgrade-available")) check: #{ available: #t endpoint: "/node/api/version" } apply: #{ available: #f reason: "not-implemented" } rollback: #{ available: #f reason: "not-implemented" } installation: #{ installed: installed strategy: (if (string=? environment "termux") "atomic-version-directory" "package-managed") platform: platform environment: environment } }) ;; The capability names advertised by authenticated sys/info are sourced ;; from the same declaration as the public posture document. (define (local-node-capability-names) #[ "fs" "proc" "sys" "git" "session" "agent" ]) ;; Closed role policy seam. Current dispatchers recognize only these ;; families; a future remote transport family must be added deliberately. (define (prefix? value prefix) (and (>= (string-length value) (string-length prefix)) (string=? (substring value 0 (string-length prefix)) prefix))) (define (local-node-allows-verb? verb) (or (prefix? verb "fs/") (prefix? verb "git/") (string=? verb "proc/run") (string=? verb "sys/info") (prefix? verb "session/") (prefix? verb "agent/")))))packages/slate-cli/src/slate-cli/node/dial.sgladded
;;; (slate-cli node dial) — transport selection for companion-to-node sessions.;;;;;; This module deliberately knows nothing about Resource/Session verbs or grants.;;; Both connectors return an already-authenticated carrier for the existing wire;;; protocol. Keeping selection here prevents a direct path from becoming a;;; second authorization model.(define-library (slate-cli node dial) (import (sigil core) (sigil time)) (export node-dial node-dial-failure-class direct-advertisement-current?) (begin (define public-failure-classes '(timeout refused stale-advertisement network authentication unavailable)) ;;; Collapse connector errors before they reach status or logs. In ;;; particular, never expose an endpoint, ticket, exception, or OS error. (define (node-dial-failure-class value) (let ((candidate (cond ((symbol? value) value) ((dict? value) (dict-ref value reason: 'unavailable)) (else 'unavailable)))) (if (member candidate public-failure-classes) candidate 'unavailable))) (define (direct-advertisement-current? advertisement now) (and (dict? advertisement) (let ((endpoint (dict-ref advertisement endpoint: #f)) (expires-at (dict-ref advertisement expires-at: #f))) (and (string? endpoint) (not (string=? endpoint "")) (number? expires-at) (> expires-at now))))) (define (accepted result) (and (dict? result) (dict-ref result connection: #f))) (define (relay-result target relay-connect reason started now-ms) ;; relay-connect is invoked only here, after the direct outcome is known. ;; Its implementation must ask Enclave for a fresh relay ticket; a direct ;; rendezvous credential is never passed into this callback. (let ((relay (guard (e (#t #f)) (relay-connect target)))) (if (accepted relay) #{ transport: 'relay connection: (dict-ref relay connection: #f) fallback-reason: reason elapsed-ms: (- (now-ms) started) } #{ transport: 'unavailable connection: #f fallback-reason: reason relay-failure: (node-dial-failure-class relay) elapsed-ms: (- (now-ms) started) }))) ;;; Select a carrier for one session. ;;; ;;; direct-connect receives the authenticated, broker-supplied ;;; advertisement and performs ONE bounded connect+peer-auth attempt. It ;;; returns #{ connection: ... } or #{ reason: <public class> }. Timeout ;;; ownership belongs to the connector because only it owns the socket. ;;; relay-connect receives only TARGET, forcing fallback to acquire a fresh ;;; relay ticket rather than reusing a direct credential. (define (node-dial target advertisement direct-connect relay-connect . maybe-now-ms) (let* ((now-ms (if (pair? maybe-now-ms) (car maybe-now-ms) (lambda () (* (current-second) 1000)))) (started (now-ms))) (if (not (direct-advertisement-current? advertisement started)) (relay-result target relay-connect 'stale-advertisement started now-ms) (let ((direct (guard (e (#t #{ reason: 'unavailable })) (direct-connect target advertisement)))) (if (accepted direct) #{ transport: 'direct connection: (dict-ref direct connection: #f) fallback-reason: #f elapsed-ms: (- (now-ms) started) } (relay-result target relay-connect (node-dial-failure-class direct) started now-ms)))))) ))packages/slate-cli/src/slate-cli/node/direct.sgladded
;;; (slate-cli node direct) — direct rendezvous ticket and peer proofs.(define-library (slate-cli node direct) (import (sigil core) (sigil math) (sigil string) (sigil crypto) (sigil time) (slate-cli node pairing)) (export direct-ticket-from-message direct-ticket-current? direct-ticket-transcript direct-caller-proof direct-acceptor-proof direct-verify-caller? direct-verify-acceptor? make-direct-ticket-store direct-ticket-store-put! direct-ticket-store-find direct-ticket-store-await direct-ticket-store-consume! direct-new-nonce direct-caller-hello direct-broker-hello? direct-accept-challenge direct-caller-response direct-accept-response direct-caller-connect direct-test-mutate-control direct-test-ticket-for-attempt paired-direct-make paired-direct-start paired-direct-handle paired-direct-phase paired-direct-message? paired-direct-disconnect paired-direct-caller-connect) (begin (define (direct-ticket-from-message message . maybe-now) (and (pair? message) (eq? (car message) 'direct-ticket-message) (member (length (cdr message)) '(7 15)) (let* ((fields (cdr message)) (role-text (list-ref fields 0)) (role (cond ((string=? role-text "caller") 'caller) ((string=? role-text "acceptor") 'acceptor) (else #f))) (ttl (guard (e (#t #f)) (string->number (list-ref fields 6)))) (now (if (pair? maybe-now) (car maybe-now) (current-second)))) (and role (integer? ttl) (> ttl 0) (<= ttl 30) #{ role: role peer: (list-ref fields 1) request-id: (list-ref fields 2) endpoint: (list-ref fields 3) caller-key: (list-ref fields 4) acceptor-key: (list-ref fields 5) expires-at: (+ now ttl) used?: #f caller-bot-id: (if (= (length fields) 15) (list-ref fields 7) "") caller-key-id: (if (= (length fields) 15) (list-ref fields 8) "") caller-generation: (if (= (length fields) 15) (string->number (list-ref fields 9)) 0) caller-public-key: (if (= (length fields) 15) (list-ref fields 10) "") acceptor-bot-id: (if (= (length fields) 15) (list-ref fields 11) "") acceptor-key-id: (if (= (length fields) 15) (list-ref fields 12) "") acceptor-generation: (if (= (length fields) 15) (string->number (list-ref fields 13)) 0) acceptor-public-key: (if (= (length fields) 15) (list-ref fields 14) "") })))) (define (direct-ticket-current? ticket now) (and (dict? ticket) (not (dict-ref ticket used?: #t)) (> (dict-ref ticket expires-at: 0) now))) ;;; Length-prefix every field so concatenation cannot create ambiguous ;;; transcripts. Peer names and request ids are authenticated context. (define (transcript-field value) (string-append (number->string (string-length value)) ":" value)) (define (direct-ticket-transcript caller acceptor request-id caller-nonce acceptor-nonce) (string-append "slate-direct-v1|" (transcript-field caller) "|" (transcript-field acceptor) "|" (transcript-field request-id) "|" (transcript-field caller-nonce) "|" (transcript-field acceptor-nonce))) (define (direct-caller-proof ticket transcript) (hmac-sha256 (dict-ref ticket caller-key: "") transcript)) (define (direct-acceptor-proof ticket transcript) (hmac-sha256 (dict-ref ticket acceptor-key: "") transcript)) (define (direct-verify-caller? ticket transcript proof) (and (string? proof) (timing-safe-equal? (direct-caller-proof ticket transcript) proof))) (define (direct-verify-acceptor? ticket transcript proof) (and (string? proof) (timing-safe-equal? (direct-acceptor-proof ticket transcript) proof))) ;;; Process-local rendezvous inbox. Tickets are intentionally neither ;;; durable nor shared with relay tickets. The vector is a mutable cell ;;; containing an alist keyed by role/peer/request-id. (define (make-direct-ticket-store) (vector '())) (define (ticket-key ticket) (list (dict-ref ticket role: #f) (dict-ref ticket peer: "") (dict-ref ticket request-id: ""))) (define (same-key? a b) (equal? (ticket-key a) (ticket-key b))) (define (direct-ticket-store-put! store ticket . maybe-now) (let ((now (if (pair? maybe-now) (car maybe-now) (current-second)))) (and (direct-ticket-current? ticket now) (begin (vector-set! store 0 (cons ticket (filter (lambda (old) (and (direct-ticket-current? old now) (not (same-key? old ticket)))) (vector-ref store 0)))) #t)))) (define (direct-ticket-store-find store role peer request-id now) (let loop ((tickets (vector-ref store 0))) (and (pair? tickets) (let ((ticket (car tickets))) (if (and (eq? role (dict-ref ticket role: #f)) (string=? peer (dict-ref ticket peer: "")) (string=? request-id (dict-ref ticket request-id: "")) (direct-ticket-current? ticket now)) ticket (loop (cdr tickets))))))) ;;; Enclave sends the caller and acceptor copies independently. A caller ;;; can receive its copy, cross the network, and present `direct/hello` ;;; before the acceptor's control-plane task has stored its copy. Await ;;; only that exact role/peer/request tuple, under an injected monotonic ;;; deadline. `pause` must yield the ambient scheduler; no listener-wide ;;; lock or credential copy is introduced here. (define (direct-ticket-store-await store role peer request-id now-ms timeout-ms pause) (let ((started (now-ms))) (let loop () (let* ((now (now-ms)) (ticket (direct-ticket-store-find store role peer request-id (quotient now 1000)))) (cond (ticket ticket) ((>= (- now started) timeout-ms) #f) (else (pause) (loop))))))) ;;; Remove before returning: even if the caller subsequently raises, the ;;; credential cannot be replayed. (define (direct-ticket-store-consume! store ticket now) (and (direct-ticket-current? ticket now) (let ((found #f)) (vector-set! store 0 (filter (lambda (item) (if (and (not found) (eq? item ticket)) (begin (set! found #t) #f) (direct-ticket-current? item now))) (vector-ref store 0))) found))) (define (direct-new-nonce) (base64url-encode (random-bytes 18))) ;;; The pre-release direct protocol has one wire version and two explicit ;;; authentication modes. These messages are the durable-pairing branch; ;;; broker tickets continue to use the direct/* branch below. (define (paired-direct-message? message) (and (dict? message) (= (dict-ref message version: 0) 1) (equal? (dict-ref message auth: #f) "paired") (member (dict-ref message t: #f) '("direct/hello" "direct/challenge" "direct/proof" "direct/ok")))) (define (paired-lp value) (let ((text (cond ((string? value) value) ((number? value) (number->string value)) (else (error "paired direct: invalid transcript field"))))) (string-append (number->string (string-length text)) ":" text))) (define (paired-transcript state caller-ephemeral acceptor-ephemeral caller-nonce acceptor-nonce connection-id) (let* ((identity (dict-ref state identity: #{})) (peer (dict-ref state peer: #{})) (caller? (eq? (dict-ref state role: #f) 'caller)) (caller-bot (if caller? (dict-ref state local-bot-id: "") (dict-ref peer bot-id: ""))) (acceptor-bot (if caller? (dict-ref peer bot-id: "") (dict-ref state local-bot-id: ""))) (caller-key (if caller? (dict-ref identity key-id: "") (dict-ref peer peer-key-id: ""))) (acceptor-key (if caller? (dict-ref peer peer-key-id: "") (dict-ref identity key-id: ""))) (caller-gen (if caller? (dict-ref identity generation: 0) (dict-ref peer peer-generation: 0))) (acceptor-gen (if caller? (dict-ref peer peer-generation: 0) (dict-ref identity generation: 0)))) (string-join (map paired-lp (list "slate-paired-direct-v1" caller-bot acceptor-bot caller-key caller-gen acceptor-key acceptor-gen caller-nonce acceptor-nonce caller-ephemeral acceptor-ephemeral connection-id)) "|"))) (define (paired-direct-make role local-bot-id identity peer . rest) (unless (member role '(caller acceptor)) (error "paired direct: invalid role")) (unless (and (string? local-bot-id) (> (string-length local-bot-id) 0) (pairing-identity-valid? identity) (pairing-record-valid? peer) (not (dict-ref peer revoked: #t))) (error "paired direct: invalid identity or pairing")) (let ((ephemeral (pairing-generate-keypair))) #{ role: role phase: (if (eq? role 'caller) 'new 'waiting-hello) local-bot-id: local-bot-id identity: identity peer: peer nonce: (if (pair? rest) (car rest) (direct-new-nonce)) connection-id: (if (and (pair? rest) (pair? (cdr rest))) (cadr rest) (if (eq? role 'acceptor) (direct-new-nonce) "")) ephemeral: ephemeral transcript: #f confirmation-key: #f })) (define (paired-direct-phase state) (dict-ref state phase: 'invalid)) (define (paired-out state send authed?) #{ state: state send: send authed?: authed? }) (define (paired-fail state) (paired-out (dict-set (dict-set (dict-set (dict-set (dict-set state phase: 'failed) ephemeral: #f) confirmation-key: #f) transcript: #f) nonce: #f) #f #f)) (define (paired-confirmation-valid? expected actual) (and (string? expected) (string? actual) (timing-safe-equal? expected actual))) (define (paired-descriptors-match? state message peer-prefix) (let ((peer (dict-ref state peer: #{}))) (and (equal? (dict-ref message (if peer-prefix caller-bot-id: acceptor-bot-id:) #f) (dict-ref peer bot-id: #f)) (equal? (dict-ref message (if peer-prefix caller-key-id: acceptor-key-id:) #f) (dict-ref peer peer-key-id: #f)) (= (dict-ref message (if peer-prefix caller-generation: acceptor-generation:) 0) (dict-ref peer peer-generation: 0))))) (define (paired-direct-start state) (if (and (eq? (dict-ref state role: #f) 'caller) (eq? (paired-direct-phase state) 'new)) (let* ((identity (dict-ref state identity: #{})) (peer (dict-ref state peer: #{})) (next (dict-set state phase: 'waiting-challenge))) (paired-out next #{ t: "direct/hello" version: 1 auth: "paired" caller-bot-id: (dict-ref state local-bot-id: "") caller-key-id: (dict-ref identity key-id: "") caller-generation: (dict-ref identity generation: 0) acceptor-bot-id: (dict-ref peer bot-id: "") acceptor-key-id: (dict-ref peer peer-key-id: "") acceptor-generation: (dict-ref peer peer-generation: 0) caller-nonce: (dict-ref state nonce: "") caller-ephemeral: (dict-ref (dict-ref state ephemeral: #{}) public-key: "") } #f)) (paired-out state #f #f))) (define (paired-direct-handle state message) (let ((role (dict-ref state role: #f)) (phase (paired-direct-phase state)) (identity (dict-ref state identity: #{})) (peer (dict-ref state peer: #{}))) (cond ((and (eq? role 'acceptor) (eq? phase 'waiting-hello) (paired-direct-message? message) (equal? (dict-ref message t: #f) "direct/hello") (paired-descriptors-match? state message #t) (equal? (dict-ref message acceptor-bot-id: #f) (dict-ref state local-bot-id: #f)) (equal? (dict-ref message acceptor-key-id: #f) (dict-ref identity key-id: #f)) (= (dict-ref message acceptor-generation: 0) (dict-ref identity generation: 0))) (let* ((caller-ephemeral (dict-ref message caller-ephemeral: #f)) (caller-nonce (dict-ref message caller-nonce: #f)) (acceptor-ephemeral (dict-ref (dict-ref state ephemeral: #{}) public-key: "")) (transcript (and (string? caller-ephemeral) (string? caller-nonce) (paired-transcript state caller-ephemeral acceptor-ephemeral caller-nonce (dict-ref state nonce: "") (dict-ref state connection-id: "")))) (shared (and transcript (pairing-shared-secret (dict-ref (dict-ref state ephemeral: #{}) private-key: "") caller-ephemeral))) (key (and shared (pairing-confirmation-key shared transcript)))) (if (not key) (paired-fail state) (let ((next (dict-set (dict-set (dict-set state phase: 'waiting-proof) transcript: transcript) confirmation-key: key))) (paired-out next #{ t: "direct/challenge" version: 1 auth: "paired" acceptor-nonce: (dict-ref state nonce: "") acceptor-ephemeral: acceptor-ephemeral connection-id: (dict-ref state connection-id: "") signature: (pairing-sign (dict-ref identity private-key: "") transcript) } #f))))) ((and (eq? role 'caller) (eq? phase 'waiting-challenge) (paired-direct-message? message) (equal? (dict-ref message t: #f) "direct/challenge")) (let* ((acceptor-ephemeral (dict-ref message acceptor-ephemeral: #f)) (acceptor-nonce (dict-ref message acceptor-nonce: #f)) (connection-id (dict-ref message connection-id: #f)) (caller-ephemeral (dict-ref (dict-ref state ephemeral: #{}) public-key: "")) (transcript (and (string? acceptor-ephemeral) (string? acceptor-nonce) (string? connection-id) (paired-transcript state caller-ephemeral acceptor-ephemeral (dict-ref state nonce: "") acceptor-nonce connection-id))) (valid (and transcript (pairing-verify? (dict-ref peer peer-public-key: "") transcript (dict-ref message signature: "")))) (shared (and valid (pairing-shared-secret (dict-ref (dict-ref state ephemeral: #{}) private-key: "") acceptor-ephemeral))) (key (and shared (pairing-confirmation-key shared transcript)))) (if (not key) (paired-fail state) (let ((next (dict-set (dict-set (dict-set state phase: 'waiting-ok) transcript: transcript) confirmation-key: key))) (paired-out next #{ t: "direct/proof" version: 1 auth: "paired" signature: (pairing-sign (dict-ref identity private-key: "") transcript) confirmation: (pairing-confirmation key "caller" transcript) } #f))))) ((and (eq? role 'acceptor) (eq? phase 'waiting-proof) (paired-direct-message? message) (equal? (dict-ref message t: #f) "direct/proof")) (let* ((transcript (dict-ref state transcript: "")) (key (dict-ref state confirmation-key: #f)) (valid (and (pairing-verify? (dict-ref peer peer-public-key: "") transcript (dict-ref message signature: "")) (paired-confirmation-valid? (pairing-confirmation key "caller" transcript) (dict-ref message confirmation: ""))))) (if (not valid) (paired-fail state) (let ((next (dict-set (dict-set (dict-set (dict-set state phase: 'active) ephemeral: #f) confirmation-key: #f) transcript: #f))) (paired-out next #{ t: "direct/ok" version: 1 auth: "paired" transport: "direct" confirmation: (pairing-confirmation key "acceptor" transcript) } #t))))) ((and (eq? role 'caller) (eq? phase 'waiting-ok) (paired-direct-message? message) (equal? (dict-ref message t: #f) "direct/ok")) (let* ((transcript (dict-ref state transcript: "")) (key (dict-ref state confirmation-key: #f)) (valid (paired-confirmation-valid? (pairing-confirmation key "acceptor" transcript) (dict-ref message confirmation: "")))) (if (not valid) (paired-fail state) (paired-out (dict-set (dict-set (dict-set (dict-set state phase: 'active) ephemeral: #f) confirmation-key: #f) transcript: #f) #f #t)))) (else (paired-fail state))))) (define (paired-direct-disconnect state) (if (eq? (paired-direct-phase state) 'active) state #f)) (define (paired-direct-caller-connect local-bot identity peer transport timeout-ms) (define open (dict-ref transport open: (lambda (endpoint) #f))) ;; Some native transports complete their handshake synchronously but do ;; not publish the open state until their receive pump runs once. Keep ;; that transport detail out of the authentication state machine while ;; still refusing to send the first proof onto a not-yet-open socket. (define connected? (dict-ref transport connected?: (lambda (conn) #t))) (define send (dict-ref transport send: (lambda (conn message) #f))) (define receive (dict-ref transport receive: (lambda (conn) #f))) (define close (dict-ref transport close: (lambda (conn) #f))) (define now-ms (dict-ref transport now-ms: (lambda () (* (current-second) 1000)))) (define pause (dict-ref transport pause: (lambda () #f))) (define started (now-ms)) (define (failed conn reason) (when conn (guard (e (#t #f)) (close conn))) #{ connection: #f reason: reason }) (define (await-connected conn) (let loop () (cond ((guard (e (#t #f)) (connected? conn)) #t) ((>= (- (now-ms) started) timeout-ms) #f) (else ;; `receive` is also the native WebSocket state pump. No peer ;; protocol message can precede our hello, so discarding #f here ;; cannot consume authenticated traffic. (let ((message (guard (e (#t 'closed)) (receive conn)))) (if (eq? message 'closed) #f (begin (pause) (loop)))))))) (define (exchange endpoint) (let ((conn (guard (e (#t #f)) (open endpoint)))) (if (not conn) #f (if (not (await-connected conn)) (begin (guard (e (#t #f)) (close conn)) #f) (let* ((initial (paired-direct-make 'caller local-bot identity peer)) (start (paired-direct-start initial))) (if (not (guard (e (#t #f)) (send conn (dict-ref start send: #{})))) (begin (guard (e (#t #f)) (close conn)) #f) (let loop ((state (dict-ref start state: #{}))) (if (>= (- (now-ms) started) timeout-ms) (begin (guard (e (#t #f)) (close conn)) #f) (let ((message (guard (e (#t 'closed)) (receive conn)))) (cond ((not message) (pause) (loop state)) ((eq? message 'closed) (guard (e (#t #f)) (close conn)) #f) (else (let* ((out (paired-direct-handle state message)) (next (dict-ref out state: #{})) (reply (dict-ref out send: #f))) (cond ((eq? (paired-direct-phase next) 'failed) (guard (e (#t #f)) (close conn)) #f) ((and reply (not (guard (e (#t #f)) (send conn reply)))) (guard (e (#t #f)) (close conn)) #f) ((dict-ref out authed?: #f) conn) (else (loop next))))))))))))))) (if (or (not (pairing-identity-valid? identity)) (not (pairing-record-valid? peer)) (dict-ref peer revoked: #t)) #{ connection: #f reason: 'authentication } (let loop ((endpoints (dict-ref peer endpoints: '()))) (cond ((null? endpoints) #{ connection: #f reason: 'unavailable }) ((>= (- (now-ms) started) timeout-ms) #{ connection: #f reason: 'timeout }) (else (let ((conn (exchange (car endpoints)))) (if conn #{ connection: conn reason: #f } (loop (cdr endpoints))))))))) (define (direct-caller-hello local-node ticket nonce) #{ t: "direct/hello" version: 1 auth: "broker" caller: local-node acceptor: (dict-ref ticket peer: "") request-id: (dict-ref ticket request-id: "") caller-nonce: nonce }) ;;; Cheap structural gate before an acceptor yields while awaiting its ;;; independently delivered ticket. Invalid or misaddressed sockets fail ;;; immediately rather than consuming the bounded wait budget. (define (direct-broker-hello? hello local-node) (and (dict? hello) (equal? (dict-ref hello t: #f) "direct/hello") (= (dict-ref hello version: 0) 1) (equal? (dict-ref hello auth: #f) "broker") (let ((caller (dict-ref hello caller: #f)) (acceptor (dict-ref hello acceptor: #f)) (request-id (dict-ref hello request-id: #f)) (nonce (dict-ref hello caller-nonce: #f))) (and (string? caller) (> (string-length caller) 0) (string? acceptor) (string=? acceptor local-node) (string? request-id) (> (string-length request-id) 0) (string? nonce) (> (string-length nonce) 0))))) ;;; Validate the caller's unauthenticated hello against an acceptor ticket ;;; and return both the public challenge and private transcript state. (define (direct-accept-challenge local-node ticket hello acceptor-nonce now) (and (direct-ticket-current? ticket now) (dict? hello) (equal? (dict-ref hello t: #f) "direct/hello") (= (dict-ref hello version: 0) 1) (equal? (dict-ref hello auth: #f) "broker") (equal? (dict-ref hello caller: #f) (dict-ref ticket peer: #f)) (equal? (dict-ref hello acceptor: #f) local-node) (equal? (dict-ref hello request-id: #f) (dict-ref ticket request-id: #f)) (let* ((caller-nonce (dict-ref hello caller-nonce: #f)) (transcript (and (string? caller-nonce) (> (string-length caller-nonce) 0) (direct-ticket-transcript (dict-ref ticket peer: "") local-node (dict-ref ticket request-id: "") caller-nonce acceptor-nonce)))) (and transcript #{ transcript: transcript challenge: #{ t: "direct/challenge" version: 1 auth: "broker" request-id: (dict-ref ticket request-id: "") acceptor-nonce: acceptor-nonce proof: (direct-acceptor-proof ticket transcript) } })))) ;;; Verify the acceptor before revealing the caller proof. (define (direct-caller-response local-node ticket caller-nonce challenge now) (and (direct-ticket-current? ticket now) (dict? challenge) (equal? (dict-ref challenge t: #f) "direct/challenge") (= (dict-ref challenge version: 0) 1) (equal? (dict-ref challenge auth: #f) "broker") (equal? (dict-ref challenge request-id: #f) (dict-ref ticket request-id: #f)) (let* ((acceptor-nonce (dict-ref challenge acceptor-nonce: #f)) (transcript (and (string? acceptor-nonce) (> (string-length acceptor-nonce) 0) (direct-ticket-transcript local-node (dict-ref ticket peer: "") (dict-ref ticket request-id: "") caller-nonce acceptor-nonce)))) (and transcript (direct-verify-acceptor? ticket transcript (dict-ref challenge proof: #f))Showing the first 500 of 646 diff lines for this file. This diff is INCOMPLETE; read the file or clone the repository for the rest.
packages/slate-cli/src/slate-cli/node/gateway.sgladded
;;; (slate-cli node gateway) — loopback carrier multiplexing for remote nodes.;;;;;; The browser already uses one authenticated WebSocket for the local node.;;; Remote carriers therefore need their own id namespace: their native node;;; frames cannot be injected into the local connection without request/channel;;; collisions. This module is deliberately pure socket-independent machinery.(define-library (slate-cli node gateway) (import (sigil math) (slate-cli wire)) (export GATEWAY-FRAME gateway-frame-encode gateway-frame-decode gateway-stamp-trace-received gateway-text-envelope gateway-text-envelope? make-gateway-registry gateway-register! gateway-ref gateway-bind! gateway-remove! gateway-remove-owner! gateway-owned? gateway-find-request gateway-ticket-entry gateway-mark-relay! gateway-relay-completion gateway-opening-expired? gateway-opened-event gateway-failed-event gateway-remove-paired! gateway-valid-target? gateway-count gateway-entries) (begin ;; Binary loopback envelope: ;; [0x11 gateway-frame][u32 carrier-id BE][original node frame bytes] ;; Existing local-node frame types occupy 0x02..0x10. Keeping a distinct ;; outer type makes accidental delivery to the local session dispatcher a ;; clean unknown frame rather than a plausible request for the wrong node. (define GATEWAY-FRAME #x11) (define max-carrier-id 4294967295) (define (write-u32-be! bytes offset value) (bytevector-u8-set! bytes offset (modulo (quotient value 16777216) 256)) (bytevector-u8-set! bytes (+ offset 1) (modulo (quotient value 65536) 256)) (bytevector-u8-set! bytes (+ offset 2) (modulo (quotient value 256) 256)) (bytevector-u8-set! bytes (+ offset 3) (modulo value 256))) (define (read-u32-be bytes offset) (+ (* (bytevector-u8-ref bytes offset) 16777216) (* (bytevector-u8-ref bytes (+ offset 1)) 65536) (* (bytevector-u8-ref bytes (+ offset 2)) 256) (bytevector-u8-ref bytes (+ offset 3)))) (define (valid-carrier-id? value) (and (integer? value) (> value 0) (<= value max-carrier-id))) (define (gateway-frame-encode carrier payload) (unless (valid-carrier-id? carrier) (error "gateway-frame-encode: invalid carrier id" carrier)) (unless (bytevector? payload) (error "gateway-frame-encode: payload must be bytes")) (let* ((length (bytevector-length payload)) (out (make-bytevector (+ length 5) 0))) (bytevector-u8-set! out 0 GATEWAY-FRAME) (write-u32-be! out 1 carrier) (let loop ((i 0)) (when (< i length) (bytevector-u8-set! out (+ i 5) (bytevector-u8-ref payload i)) (loop (+ i 1)))) out)) (define (gateway-frame-decode bytes) (and (bytevector? bytes) (>= (bytevector-length bytes) 5) (= (bytevector-u8-ref bytes 0) GATEWAY-FRAME) (let ((carrier (read-u32-be bytes 1))) (and (valid-carrier-id? carrier) #{ carrier: carrier payload: (bytevector-copy bytes 5 (bytevector-length bytes)) })))) ;; Capture-only attribution: preserve every ordinary inner frame exactly, ;; but extend a v1 trace reply with the caller gateway's receive clock. ;; The browser compares this modulo-u32 epoch with its keydown epoch; no ;; terminal bytes or normal protocol frames are modified. (define (gateway-stamp-trace-received bytes now-ms . maybe-outbound-ms) (let ((frame (and (bytevector? bytes) (decode-stream-frame bytes)))) (if (and frame (= (stream-frame-type frame) frame-trace-out) (= (bytevector-length (stream-frame-payload frame)) 12) (integer? now-ms)) (let* ((payload (stream-frame-payload frame)) (has-outbound? (and (pair? maybe-outbound-ms) (integer? (car maybe-outbound-ms)))) (stamped (make-bytevector (if has-outbound? 20 16) 0))) (bytevector-copy! stamped 0 payload 0 12) (if has-outbound? (begin (write-u32-be! stamped 12 (modulo (car maybe-outbound-ms) 4294967296)) (write-u32-be! stamped 16 (modulo now-ms 4294967296))) (write-u32-be! stamped 12 (modulo now-ms 4294967296))) (encode-stream-frame frame-trace-out (stream-frame-id frame) stamped)) bytes))) ;; Text frames remain JSON control envelopes at the listener boundary. The ;; inner value is the exact original node protocol JSON string, not decoded ;; and re-encoded by the gateway. (define (gateway-text-envelope carrier payload) (unless (valid-carrier-id? carrier) (error "gateway-text-envelope: invalid carrier id" carrier)) (unless (string? payload) (error "gateway-text-envelope: payload must be text")) #{ t: "remote/text" carrier: carrier payload: payload }) (define (gateway-text-envelope? value) (and (dict? value) (equal? (dict-ref value t: #f) "remote/text") (valid-carrier-id? (dict-ref value carrier: #f)) (string? (dict-ref value payload: #f)))) ;;; Enclave node identities use the IRC-safe token alphabet. Refuse spaces, ;;; separators, URLs, and oversized values at loopback before allocating ;;; state. Enclave remains authoritative for existence and common ownership. (define (gateway-valid-target? target) (define (safe-char? ch) (or (and (char>=? ch #\a) (char<=? ch #\z)) (and (char>=? ch #\A) (char<=? ch #\Z)) (and (char>=? ch #\0) (char<=? ch #\9)) (char=? ch #\-) (char=? ch #\_))) (and (string? target) (> (string-length target) 0) (<= (string-length target) 64) (let loop ((i 0)) (or (= i (string-length target)) (and (safe-char? (string-ref target i)) (loop (+ i 1))))))) ;; Registry vector slots: next-id, entries. Entries are public non-secret ;; status records. The live connection is retained for routing but callers ;; must not serialize an entry directly onto status/log surfaces. (define (make-gateway-registry) (vector 0 '())) (define (gateway-entries registry) (vector-ref registry 1)) (define (gateway-count registry) (length (gateway-entries registry))) (define (gateway-ref registry carrier) (let loop ((entries (gateway-entries registry))) (and (pair? entries) (let ((entry (car entries))) (if (= carrier (dict-ref entry carrier: 0)) entry (loop (cdr entries))))))) (define (gateway-find-request registry request-id) (and (string? request-id) (let loop ((entries (gateway-entries registry))) (and (pair? entries) (let ((entry (car entries))) (if (equal? request-id (dict-ref entry request-id: #f)) entry (loop (cdr entries)))))))) ;;; A caller ticket completes exactly one still-opening carrier. Role, ;;; correlation id, and peer identity are all load-bearing: a ticket for a ;;; different target must never be attached merely because its id parses. (define (gateway-ticket-entry registry ticket) (and (dict? ticket) (eq? (dict-ref ticket role: #f) 'caller) (let ((entry (gateway-find-request registry (dict-ref ticket request-id: #f)))) (and entry ;; Relay is armed before DIRECT-OPEN so fallback never has ;; to send from the control socket's receive continuation. ;; A direct ticket may still win while that relay request is ;; in flight; once either transport binds, state is `ready` ;; and the loser is rejected here/on relay completion. (member (dict-ref entry state: #f) '(opening opening-relay)) (equal? (dict-ref entry target: #f) (dict-ref ticket peer: #f)) (dict-ref entry owner: #f) entry)))) (define (carrier-in-use? registry carrier) (and (gateway-ref registry carrier) #t)) (define (allocate-carrier! registry) (let loop ((candidate (+ (vector-ref registry 0) 1)) (attempts 0)) (let ((id (if (> candidate max-carrier-id) 1 candidate))) (cond ((>= attempts max-carrier-id) (error "gateway carrier id space exhausted")) ((carrier-in-use? registry id) (loop (+ id 1) (+ attempts 1))) (else (vector-set! registry 0 id) id))))) (define (gateway-register! registry target . metadata) (unless (gateway-valid-target? target) (error "gateway-register!: target must be a node identity")) (let* ((meta (if (pair? metadata) (car metadata) #{})) (carrier (allocate-carrier! registry)) (entry #{ carrier: carrier target: target state: 'opening transport: #f fallback-reason: #f connection: #f owner: (dict-ref meta owner: #f) client-request: (dict-ref meta client-request: #f) opened-at: (dict-ref meta opened-at: #f) request-id: (dict-ref meta request-id: (string-append "g" (number->string carrier))) })) (vector-set! registry 1 (cons entry (gateway-entries registry))) entry)) (define (gateway-owned? registry carrier owner) (let ((entry (gateway-ref registry carrier))) (and entry owner (eq? owner (dict-ref entry owner: #f))))) (define (replace-entry! registry carrier replacement) (let ((found #f)) (vector-set! registry 1 (map (lambda (entry) (if (= carrier (dict-ref entry carrier: 0)) (begin (set! found #t) replacement) entry)) (gateway-entries registry))) (and found replacement))) (define (gateway-bind! registry carrier connection transport fallback-reason . auth) (let ((entry (gateway-ref registry carrier))) (and entry connection (member transport '(direct relay)) (replace-entry! registry carrier (dict-set (dict-set (dict-set (dict-set (dict-set entry state: 'ready) connection: connection) transport: transport) fallback-reason: fallback-reason) auth-kind: (if (pair? auth) (car auth) 'broker)))))) (define (gateway-remove-paired! registry peer close!) (let ((targets (filter (lambda (entry) (and (eq? (dict-ref entry auth-kind: #f) 'paired) (or (not peer) (equal? peer (dict-ref entry target: #f))))) (gateway-entries registry)))) (for-each (lambda (entry) (gateway-remove! registry (dict-ref entry carrier: 0) close!)) targets) (length targets))) (define (gateway-mark-relay! registry carrier reason) (let ((entry (gateway-ref registry carrier))) (and entry (eq? (dict-ref entry state: #f) 'opening) (replace-entry! registry carrier (dict-set (dict-set entry state: 'opening-relay) fallback-reason: reason))))) (define (gateway-opening-expired? entry now timeout) (let ((opened-at (dict-ref entry opened-at: #f))) (and (member (dict-ref entry state: #f) '(opening opening-relay)) (number? opened-at) (number? now) (number? timeout) (>= (- now opened-at) timeout)))) ;;; Validate the client half of Enclave's stream/ticket response without ;;; retaining it in registry state. SENDER must remain the target node and ;;; the carrier must already have crossed the one-way opening-relay gate. (define (gateway-relay-completion registry sender request-id verb payload now) (and (string? sender) (string? request-id) (equal? verb "stream/ticket") (dict? payload) (let* ((entry (gateway-find-request registry request-id)) (ticket (dict-ref payload ticket: #f)) (ttl-raw (dict-ref payload expires-in: #f)) (ttl (cond ((integer? ttl-raw) ttl-raw) ((string? ttl-raw) (guard (e (#t #f)) (string->number ttl-raw))) (else #f)))) (and entry (eq? (dict-ref entry state: #f) 'opening-relay) (equal? sender (dict-ref entry target: #f)) (string? ticket) (not (string=? ticket "")) (integer? ttl) (> ttl 0) (<= ttl 60) #{ entry: entry ticket: ticket expires-at: (+ now ttl) pairing-descriptors: (dict-ref payload pairing-descriptors: '()) })))) ;;; Browser events are constructed from an explicit safe field set. In ;;; particular, CONNECTION, OWNER, TARGET, and REQUEST-ID never cross the ;;; loopback status surface. (define (gateway-opened-event entry) (and (dict? entry) (eq? (dict-ref entry state: #f) 'ready) #{ t: "ev" event: "remote/opened" carrier: (dict-ref entry carrier: #f) request: (dict-ref entry client-request: #f) transport: (if (eq? (dict-ref entry transport: #f) 'direct) "direct" "relay") fallback-reason: (let ((reason (dict-ref entry fallback-reason: #f))) (and reason (symbol->string reason))) })) (define (gateway-failed-event entry direct-failure relay-failure) #{ t: "ev" event: "remote/failed" carrier: (dict-ref entry carrier: #f) request: (dict-ref entry client-request: #f) reason: "unavailable" direct-failure: (symbol->string direct-failure) relay-failure: (symbol->string relay-failure) }) ;;; Remove before closing. A close callback that re-enters the gateway can ;;; never find and route another frame onto the dying carrier. (define (gateway-remove! registry carrier . maybe-close) (let ((entry (gateway-ref registry carrier))) (and entry (begin (vector-set! registry 1 (filter (lambda (item) (not (= carrier (dict-ref item carrier: 0)))) (gateway-entries registry))) (when (and (pair? maybe-close) (dict-ref entry connection: #f)) (guard (e (#t #f)) ((car maybe-close) (dict-ref entry connection: #f)))) entry)))) ;;; Drop every carrier belonging to one loopback connection. Removal of the ;;; complete set happens before any close callback runs, giving disconnect ;;; cleanup the same re-entrancy guarantee as gateway-remove!. (define (gateway-remove-owner! registry owner . maybe-close) (let ((removed (filter (lambda (entry) (eq? owner (dict-ref entry owner: #f))) (gateway-entries registry)))) (vector-set! registry 1 (filter (lambda (entry) (not (eq? owner (dict-ref entry owner: #f)))) (gateway-entries registry))) (when (pair? maybe-close) (for-each (lambda (entry) (let ((connection (dict-ref entry connection: #f))) (when connection (guard (e (#t #f)) ((car maybe-close) connection))))) removed)) removed)) ))packages/slate-cli/src/slate-cli/node/pairing-carrier.sgladded
;;; Shared authenticated-carrier adapter for durable node pairing.(define-library (slate-cli node pairing-carrier) (import (sigil core) (sigil json) (sigil log) (slate-cli listen) (slate-cli node pairing) (slate-cli node pairing-control)) (export pairing-carrier-init! pairing-carrier-start! pairing-carrier-handle! pairing-carrier-close!) (begin (define (persist! conn state) (let* ((state-dir (conn-state-get conn "pairing-state-dir" #f)) (record (pairing-control-record state)) (old (and state-dir (pairing-load-records state-dir))) (merged (and (list? old) record (pairing-record-merge old record)))) (and merged (begin (pairing-save-records! state-dir merged) #t)))) (define (pairing-carrier-init! conn role context identity endpoints state-dir) (conn-state-set! conn "pairing-state-dir" state-dir) (conn-state-set! conn "pairing-control" (pairing-control-make role context identity endpoints)) #t) (define (apply-result! conn result) (let ((state (dict-ref result state: #f)) (send (dict-ref result send: #f)) (activate? (dict-ref result activate?: #f))) (cond ;; The acceptor activates only after the final frame was written. ;; This is the explicitly accepted final-delivery asymmetry. ((and activate? send) (if (and (ws-send-control! conn send) (persist! conn state)) (begin (conn-state-set! conn "pairing-control" state) #t) (begin (conn-state-set! conn "pairing-control" #f) #f))) ;; The initiator has already validated pair/committed and has no ;; further write. Persisting is its activation point. (activate? (if (persist! conn state) (begin (conn-state-set! conn "pairing-control" state) #t) (begin (conn-state-set! conn "pairing-control" #f) #f))) (else (conn-state-set! conn "pairing-control" state) (when send (ws-send-control! conn send)) #t)))) (define (pairing-carrier-start! conn) (let ((state (conn-state-get conn "pairing-control" #f))) (and state (apply-result! conn (pairing-control-start state))))) ;; Returns #t when `text` belongs to the built-in pairing family, including ;; malformed pairing JSON. Ordinary Resource/Session dispatch must not see ;; a reserved family frame. (define (pairing-carrier-handle! conn text) (let ((env (guard (e (#t #f)) (json-decode text)))) (if (not (pairing-control-message? env)) #f (let ((state (conn-state-get conn "pairing-control" #f))) (when state (log-debug "pairing carrier received control" phase: (symbol->string (pairing-control-phase state)) type: (dict-ref env t: "")) (let* ((result (pairing-control-handle state env)) (next (dict-ref result state: #f))) (log-debug "pairing carrier advanced" phase: (symbol->string (pairing-control-phase next)) activate: (dict-ref result activate?: #f)) (apply-result! conn result))) #t)))) (define (pairing-carrier-close! conn) (let ((state (conn-state-get conn "pairing-control" #f))) (conn-state-set! conn "pairing-control" (and state (pairing-control-disconnect state)))))))packages/slate-cli/src/slate-cli/node/pairing-client.sgladded
;;; Initiator driver for the carrier-independent pairing state machine.(define-library (slate-cli node pairing-client) (import (sigil core) (sigil time) (sigil log) (slate-cli node pairing) (slate-cli node pairing-control)) (export pairing-client-run!) (begin (define (save-record! state-dir record) (let* ((old (pairing-load-records state-dir)) (merged (and (list? old) (pairing-record-merge old record)))) (and merged (begin (pairing-save-records! state-dir merged) #t)))) ;; Transport supplies send:/receive:/pause:/now-ms:. receive returns a ;; decoded control dict, #f when idle, or 'closed. (define (pairing-client-run! context identity endpoints state-dir transport . maybe-timeout) (let* ((timeout (if (pair? maybe-timeout) (car maybe-timeout) 2000)) (now (dict-ref transport now-ms: current-milliseconds)) (deadline (+ (now) timeout)) (send! (dict-ref transport send: #f)) (receive! (dict-ref transport receive: #f)) (pause! (dict-ref transport pause: (lambda () (sleep 0.01)))) (started (pairing-control-start (pairing-control-make 'caller context identity endpoints)))) (send! (dict-ref started send: #f)) (let loop ((state (dict-ref started state: #f))) (if (>= (now) deadline) #f (let ((message (receive!))) (cond ((or (not message) (eq? message 'idle)) (pause!) (loop state)) ((eq? message 'closed) #f) ((not (pairing-control-message? message)) #f) (else (log-debug "pairing client received control" phase: (symbol->string (pairing-control-phase state)) type: (dict-ref message t: "")) (let* ((result (pairing-control-handle state message)) (next (dict-ref result state: #f)) (reply (dict-ref result send: #f))) (log-debug "pairing client advanced" phase: (symbol->string (pairing-control-phase next)) activate: (dict-ref result activate?: #f)) (when reply (send! reply)) (if (dict-ref result activate?: #f) (and (save-record! state-dir (pairing-control-record next)) #t) (if (eq? (pairing-control-phase next) 'failed) #f (loop next)))))))))))))packages/slate-cli/src/slate-cli/node/pairing-control.sgladded
;;; Pure four-message durable node-pairing exchange.(define-library (slate-cli node pairing-control) (import (sigil core) (sigil crypto) (sigil math) (sigil time) (slate-cli node pairing)) (export pairing-control-make pairing-control-start pairing-control-handle pairing-control-disconnect pairing-control-phase pairing-control-record pairing-control-message?) (begin (define (required context key) (let ((value (dict-ref context key #f))) (if value value (error "node pairing: missing authenticated carrier field" key)))) (define (pairing-control-message? env) (and (dict? env) (member (dict-ref env t: #f) '("pair/offer" "pair/accept" "pair/commit" "pair/committed")))) ;; `context` is supplied only by an authenticated broker carrier. The ;; wire message never gets to choose either identity bound below. (define (pairing-control-make role context identity endpoints . rest) (unless (member role '(caller acceptor)) (error "node pairing: invalid role")) (unless (pairing-identity-valid? identity) (error "node pairing: invalid identity")) (for-each (lambda (key) (required context key)) (list session-id: caller-bot-id: caller-transport-nick: caller-key-id: caller-generation: caller-public-key: acceptor-bot-id: acceptor-transport-nick: acceptor-key-id: acceptor-generation: acceptor-public-key:)) #{ role: role phase: (if (eq? role 'caller) 'new 'waiting-offer) context: context identity: identity endpoints: endpoints nonce: (if (pair? rest) (car rest) (base64url-encode (random-bytes 32))) created: (if (and (pair? rest) (pair? (cdr rest))) (cadr rest) (exact (floor (current-second)))) peer-nonce: #f transcript: #f confirmation-key: #f record: #f }) (define (pairing-control-phase state) (dict-ref state phase: 'invalid)) (define (pairing-control-record state) (dict-ref state record: #f)) (define (out state send activate?) #{ state: state send: send activate?: activate? }) (define (context-transcript context caller-nonce acceptor-nonce) (pairing-transcript (dict-ref context session-id: "") (dict-ref context caller-bot-id: "") (dict-ref context caller-transport-nick: "") (dict-ref context caller-key-id: "") (dict-ref context caller-generation: 0) (dict-ref context acceptor-bot-id: "") (dict-ref context acceptor-transport-nick: "") (dict-ref context acceptor-key-id: "") (dict-ref context acceptor-generation: 0) (dict-ref context caller-public-key: "") (dict-ref context acceptor-public-key: "") caller-nonce acceptor-nonce)) (define (peer-record state) (let* ((role (dict-ref state role: #f)) (ctx (dict-ref state context: #{})) (caller? (eq? role 'caller))) #{ bot-id: (dict-ref ctx (if caller? acceptor-bot-id: caller-bot-id:) "") transport-nick: (dict-ref ctx (if caller? acceptor-transport-nick: caller-transport-nick:) "") peer-key-id: (dict-ref ctx (if caller? acceptor-key-id: caller-key-id:) "") peer-generation: (dict-ref ctx (if caller? acceptor-generation: caller-generation:) 0) peer-public-key: (dict-ref ctx (if caller? acceptor-public-key: caller-public-key:) "") endpoints: (dict-ref state endpoints: '()) created: (dict-ref state created: 0) revoked: #f })) (define (derive state transcript peer-public) (let* ((identity (dict-ref state identity: #{})) (shared (pairing-shared-secret (dict-ref identity private-key: "") peer-public)) (key (and shared (pairing-confirmation-key shared transcript)))) (and key (dict-set (dict-set state transcript: transcript) confirmation-key: key)))) (define (pairing-control-start state) (if (and (eq? (dict-ref state role: #f) 'caller) (eq? (pairing-control-phase state) 'new)) (let ((next (dict-set state phase: 'waiting-accept))) (out next #{ t: "pair/offer" nonce: (dict-ref state nonce: "") } #f)) (out state #f #f))) (define (same-confirmation? actual expected) (and (string? actual) (string? expected) (timing-safe-equal? actual expected))) (define (pairing-control-handle state env) (let ((type (and (dict? env) (dict-ref env t: #f))) (phase (pairing-control-phase state)) (role (dict-ref state role: #f)) (ctx (dict-ref state context: #{})) (identity (dict-ref state identity: #{}))) (cond ((and (eq? role 'acceptor) (eq? phase 'waiting-offer) (equal? type "pair/offer") (string? (dict-ref env nonce: #f))) (let* ((caller-nonce (dict-ref env nonce: "")) (acceptor-nonce (dict-ref state nonce: "")) (transcript (context-transcript ctx caller-nonce acceptor-nonce)) (next (derive (dict-set state peer-nonce: caller-nonce) transcript (dict-ref ctx caller-public-key: "")))) (if (not next) (out (dict-set state phase: 'failed) #f #f) (out (dict-set next phase: 'waiting-commit) #{ t: "pair/accept" nonce: acceptor-nonce signature: (pairing-sign (dict-ref identity private-key: "") transcript) } #f)))) ((and (eq? role 'caller) (eq? phase 'waiting-accept) (equal? type "pair/accept") (string? (dict-ref env nonce: #f))) (let* ((acceptor-nonce (dict-ref env nonce: "")) (transcript (context-transcript ctx (dict-ref state nonce: "") acceptor-nonce)) (valid (pairing-verify? (dict-ref ctx acceptor-public-key: "") transcript (dict-ref env signature: ""))) (next (and valid (derive (dict-set state peer-nonce: acceptor-nonce) transcript (dict-ref ctx acceptor-public-key: ""))))) (if (not next) (out (dict-set state phase: 'failed) #f #f) (out (dict-set next phase: 'waiting-committed) #{ t: "pair/commit" signature: (pairing-sign (dict-ref identity private-key: "") transcript) confirmation: (pairing-confirmation (dict-ref next confirmation-key: #f) "caller" transcript) } #f)))) ((and (eq? role 'acceptor) (eq? phase 'waiting-commit) (equal? type "pair/commit")) (let* ((transcript (dict-ref state transcript: "")) (key (dict-ref state confirmation-key: #f)) (valid (and (pairing-verify? (dict-ref ctx caller-public-key: "") transcript (dict-ref env signature: "")) (same-confirmation? (dict-ref env confirmation: "") (pairing-confirmation key "caller" transcript))))) (if (not valid) (out (dict-set state phase: 'failed) #f #f) (let* ((record (peer-record state)) (next (dict-set (dict-set state phase: 'active) record: record))) ;; The adapter persists `record` before it writes this reply. (out next #{ t: "pair/committed" confirmation: (pairing-confirmation key "acceptor" transcript) } #t))))) ((and (eq? role 'caller) (eq? phase 'waiting-committed) (equal? type "pair/committed")) (let* ((transcript (dict-ref state transcript: "")) (valid (same-confirmation? (dict-ref env confirmation: "") (pairing-confirmation (dict-ref state confirmation-key: #f) "acceptor" transcript)))) (if (not valid) (out (dict-set state phase: 'failed) #f #f) (let ((record (peer-record state))) (out (dict-set (dict-set state phase: 'active) record: record) #f #t))))) (else (out (dict-set state phase: 'failed) #f #f))))) ;; Active means durable and survives carrier loss. Every other phase is ;; pending in memory and is discarded without producing a usable record. (define (pairing-control-disconnect state) (if (eq? (pairing-control-phase state) 'active) state #f))))packages/slate-cli/src/slate-cli/node/pairing-management.sgladded
;;; Durable pairing management. All public results are reconstructed summaries.(define-library (slate-cli node pairing-management) (import (sigil core) (sigil time) (slate-cli node pairing)) (export pairing-management-list pairing-management-revoke! pairing-management-rotate! pairing-management-dispatch) (begin (define (safe-summary record) (let ((summary (pairing-public-summary record))) (dict-set summary offline-ready?: (and (not (dict-ref record revoked: #t)) (pair? (dict-ref record endpoints: '())))))) (define (pairing-management-list state-dir) (let ((records (pairing-load-records state-dir))) (unless records (error "pairing-store-corrupt" "pairing store is corrupt")) (map safe-summary records))) (define (pairing-management-revoke! state-dir peer) (unless (and (string? peer) (> (string-length peer) 0)) (error "invalid-args" "pairing revoke needs a peer identity")) (let ((records (pairing-load-records state-dir))) (unless records (error "pairing-store-corrupt" "pairing store is corrupt")) (let* ((found #f) (updated (map (lambda (record) (if (or (equal? peer (dict-ref record bot-id: #f)) (equal? peer (dict-ref record transport-nick: #f))) (begin (set! found #t) (dict-set record revoked: #t)) record)) records))) (unless found (error "not-found" "pairing not found")) (pairing-save-records! state-dir updated) (safe-summary (pairing-record-find-inclusive updated peer))))) (define (pairing-record-find-inclusive records peer) (let loop ((rest records)) (and (pair? rest) (if (or (equal? peer (dict-ref (car rest) bot-id: #f)) (equal? peer (dict-ref (car rest) transport-nick: #f))) (car rest) (loop (cdr rest)))))) (define (pairing-management-rotate! state-dir) (let ((identity (pairing-load-identity state-dir))) (unless identity (error "identity-unavailable" "direct identity is unavailable")) (let ((replacement (pairing-make-identity (+ 1 (dict-ref identity generation: 0)) (+ 1 (dict-ref identity created: 0))))) (pairing-save-identity! state-dir replacement) (pairing-public-summary replacement)))) ;;; Typed operation seam shared by the listener and CLI. The caller owns ;;; authorization; this function owns validation, persistence and redaction. (define (pairing-management-dispatch state-dir verb args) (cond ((string=? verb "pairing/list") #{ pairings: (pairing-management-list state-dir) }) ((string=? verb "pairing/revoke") #{ pairing: (pairing-management-revoke! state-dir (dict-ref args peer: #f)) }) ((string=? verb "pairing/rotate") #{ identity: (pairing-management-rotate! state-dir) }) (else (error "unknown-verb" "unknown pairing management verb"))))))packages/slate-cli/src/slate-cli/node/pairing.sgladded
;;; Durable, hub-independent node pairing primitives and stores.(define-library (slate-cli node pairing) (import (sigil string) (sigil array) (sigil io) (sigil fs) (sigil path) (sigil process) (sigil json) (sigil crypto) (sigil time) (sigil math)) (export pairing-transcript pairing-transcript-hash pairing-generate-keypair pairing-sign pairing-verify? pairing-shared-secret pairing-confirmation-key pairing-confirmation pairing-make-identity pairing-identity-valid? pairing-record-valid? pairing-record-relation pairing-record-merge pairing-record-find pairing-identity-path pairing-records-path pairing-save-identity! pairing-load-identity pairing-save-records! pairing-load-records make-pairing-identity-reader make-pairing-records-reader pairing-public-summary pairing-require-mode!) (begin (define (nonempty-string? value) (and (string? value) (> (string-length value) 0))) (define (natural? value) (and (integer? value) (> value 0))) (define (starts-with? text prefix) (and (string? text) (>= (string-length text) (string-length prefix)) (string=? (substring text 0 (string-length prefix)) prefix))) (define (lp value) (let ((text (cond ((string? value) value) ((symbol? value) (symbol->string value)) ((number? value) (number->string value)) (else (error "node pairing: invalid transcript field"))))) (string-append (number->string (string-length text)) ":" text))) ;; The order is protocol, not dictionary order. Callers supply every field ;; explicitly so a future JSON encoder cannot alter the signed bytes. (define (pairing-transcript session-id caller-bot caller-nick caller-key caller-gen acceptor-bot acceptor-nick acceptor-key acceptor-gen caller-public acceptor-public caller-nonce acceptor-nonce) (string-join (map lp (list "slate-node-pairing-v1" session-id caller-bot caller-nick caller-key caller-gen acceptor-bot acceptor-nick acceptor-key acceptor-gen caller-public acceptor-public caller-nonce acceptor-nonce)) "|")) (define (pairing-transcript-hash transcript) (sha256 transcript)) (define (encoded-key? value bytes first-byte) (and (nonempty-string? value) (guard (e (#t #f)) (let ((raw (base64url-decode value))) (and (bytevector? raw) (= (bytevector-length raw) bytes) (or (not first-byte) (= (bytevector-u8-ref raw 0) first-byte))))))) (define (public-key-valid? value) (and (encoded-key? value 65 #x04) (guard (e (#t #f)) ;; Scalar one is a fixed validation probe; ECDH rejects points ;; with valid SEC1 shape that are not actually on P-256. (let ((one (make-bytevector 32 0))) (bytevector-u8-set! one 31 1) (and (ecdh-p256-shared-secret one (decode-key value)) #t))))) (define (pairing-generate-keypair) (let ((kp (ecdsa-p256-generate-keypair))) #{ private-key: (base64url-encode (car kp)) public-key: (base64url-encode (cdr kp)) })) (define (decode-key key) (base64url-decode key)) (define (pairing-sign private-key transcript) (base64url-encode (ecdsa-p256-sign (decode-key private-key) transcript))) (define (pairing-verify? public-key transcript signature) (and (encoded-key? public-key 65 #x04) (encoded-key? signature 64 #f) (guard (e (#t #f)) (ecdsa-p256-verify (decode-key public-key) transcript (decode-key signature))))) (define (pairing-shared-secret private-key peer-public-key) (guard (e (#t #f)) (ecdh-p256-shared-secret (decode-key private-key) (decode-key peer-public-key)))) (define (pairing-confirmation-key shared transcript) (and (bytevector? shared) (hkdf-sha256 (sha256-bytes transcript) shared "slate-paired-direct-v1" 32))) (define (pairing-confirmation key role transcript) (and (bytevector? key) (hmac-sha256 key (string-append (lp role) "|" transcript)))) (define (pairing-make-identity generation . maybe-created) (let* ((kp (pairing-generate-keypair)) (public (dict-ref kp public-key: ""))) #{ version: 1 key-id: (substring (sha256 public) 0 24) generation: generation private-key: (dict-ref kp private-key: "") public-key: public ;; current-second is inexact in the native runtime. Persistent ;; generations require an integer timestamp, just like decoded JSON. created: (if (pair? maybe-created) (car maybe-created) (exact (floor (current-second)))) })) (define (pairing-identity-valid? record) (and (dict? record) (= (dict-ref record version: 0) 1) (nonempty-string? (dict-ref record key-id: #f)) (natural? (dict-ref record generation: #f)) (natural? (dict-ref record created: #f)) (encoded-key? (dict-ref record private-key: #f) 32 #f) (public-key-valid? (dict-ref record public-key: #f)) (string=? (dict-ref record key-id: "") (substring (sha256 (dict-ref record public-key: "")) 0 24)) (let* ((private (dict-ref record private-key: "")) (public (dict-ref record public-key: "")) (probe "slate-node-pairing-identity-check") (signature (guard (e (#t #f)) (pairing-sign private probe)))) (and signature (pairing-verify? public probe signature))))) (define (safe-endpoint? endpoint) (and (nonempty-string? endpoint) (or (starts-with? endpoint "ws://") (starts-with? endpoint "wss://")) (not (string-contains? endpoint "0.0.0.0")) (not (string-contains? endpoint "[::]")) (not (string-contains? endpoint "localhost")) (not (string-contains? endpoint "127.0.0.1")) (not (string-contains? endpoint "[::1]")))) (define (sequence->list value) (cond ((list? value) value) ((array? value) (array->list value)) (else #f))) (define (normalize-record record) (let ((endpoints (and (dict? record) (sequence->list (dict-ref record endpoints: #f))))) (and endpoints (dict-set record endpoints: endpoints)))) (define (pairing-record-valid? record) (and (dict? record) (nonempty-string? (dict-ref record bot-id: #f)) (nonempty-string? (dict-ref record transport-nick: #f)) (nonempty-string? (dict-ref record peer-key-id: #f)) (natural? (dict-ref record peer-generation: #f)) (public-key-valid? (dict-ref record peer-public-key: #f)) (sequence->list (dict-ref record endpoints: #f)) (every safe-endpoint? (sequence->list (dict-ref record endpoints: '()))) (natural? (dict-ref record created: #f)) (boolean? (dict-ref record revoked: #f)))) ;; Values are: identical, newer, stale, or conflict. A key change at the ;; same generation is never selected silently. (define (pairing-record-relation old new) (cond ((or (not (pairing-record-valid? old)) (not (pairing-record-valid? new))) 'invalid) ((not (string=? (dict-ref old bot-id: "") (dict-ref new bot-id: ""))) 'different) ((< (dict-ref new peer-generation: 0) (dict-ref old peer-generation: 0)) 'stale) ((> (dict-ref new peer-generation: 0) (dict-ref old peer-generation: 0)) 'newer) ((and (string=? (dict-ref old peer-key-id: "") (dict-ref new peer-key-id: "")) (string=? (dict-ref old peer-public-key: "") (dict-ref new peer-public-key: ""))) 'identical) (else 'conflict))) (define (pairing-record-merge records record) (and (pairing-record-valid? record) (let loop ((rest records) (out '()) (found #f)) (if (null? rest) (reverse (if found out (cons record out))) (let ((old (car rest))) (if (string=? (dict-ref old bot-id: "") (dict-ref record bot-id: "")) (case (pairing-record-relation old record) ((identical) (loop (cdr rest) (cons old out) #t)) ((newer) (loop (cdr rest) (cons record out) #t)) (else #f)) (loop (cdr rest) (cons old out) found))))))) (define (pairing-record-find records identity) (let loop ((rest records)) (and (pair? rest) (let ((record (car rest))) (if (and (pairing-record-valid? record) (not (dict-ref record revoked: #t)) (or (equal? identity (dict-ref record bot-id: #f)) (equal? identity (dict-ref record transport-nick: #f)))) record (loop (cdr rest))))))) (define (pairing-identity-path state-dir) (path-join state-dir "node-direct-identity.json")) (define (pairing-records-path state-dir) (path-join state-dir "node-direct-pairings.json")) (define (pairing-require-mode! target mode (keys: (exists? command-exists?) (run process-run))) (unless (exists? "chmod") (error "node pairing: chmod is required" target)) (let ((rc (guard (e (#t 1)) (run "chmod" mode target)))) (unless (and (integer? rc) (= rc 0)) (error "node pairing: could not secure state path" target)))) (define (atomic-save! state-dir path value) (let ((tmp (string-append path ".tmp"))) (ensure-directory state-dir) (pairing-require-mode! state-dir "700") (write-file-string tmp "") (guard (e (#t (when (file-exists? tmp) (delete-file tmp)) (raise e))) (pairing-require-mode! tmp "600") (write-file-string tmp (json-encode value)) (pairing-require-mode! tmp "600") (rename-file tmp path) (pairing-require-mode! path "600") path))) (define (pairing-save-identity! state-dir identity) (unless (pairing-identity-valid? identity) (error "node pairing: invalid identity")) (atomic-save! state-dir (pairing-identity-path state-dir) identity)) (define (pairing-load-identity state-dir) (let ((path (pairing-identity-path state-dir))) (and (file-exists? path) (guard (e (#t #f)) (decode-identity (read-file-string path)))))) (define (decode-identity text) (let ((record (json-decode text))) (and (pairing-identity-valid? record) record))) (define (records-valid? records) (and (list? records) (every pairing-record-valid? records) (let loop ((rest records) (seen '())) (or (null? rest) (let* ((r (car rest)) (key (dict-ref r bot-id: ""))) (and (not (member key seen)) (loop (cdr rest) (cons key seen)))))))) (define (pairing-save-records! state-dir records) (unless (records-valid? records) (error "node pairing: invalid pairing records")) (atomic-save! state-dir (pairing-records-path state-dir) #{ version: 1 pairings: records })) (define (pairing-load-records state-dir) (let ((path (pairing-records-path state-dir))) (if (not (file-exists? path)) '() (guard (e (#t #f)) (decode-records (read-file-string path)))))) (define (decode-records text) (let* ((root (json-decode text)) (raw (sequence->list (dict-ref root pairings: #f))) (records (and raw (map normalize-record raw)))) (and (= (dict-ref root version: 0) 1) (records-valid? records) records))) ;; A reader retains only its last successfully validated snapshot. Always ;; re-read the file: mtime equality can miss same-second revocations. Exact ;; contents equality avoids repeating P-256 checks on unchanged state. ;; Missing, unreadable, or invalid data discards the snapshot; no caller can ;; seed the cache with an unvalidated record. (define (make-pairing-file-reader path decode missing) (let ((cached-text #f) (cached-value #f)) (define (invalidate!) (set! cached-text #f) (set! cached-value #f)) (lambda () (guard (e (#t (invalidate!) #f)) (if (file-exists? path) (let ((text (read-file-string path))) (if (and cached-text (string=? text cached-text)) cached-value (begin (invalidate!) (let ((value (decode text))) (when value (set! cached-value value) (set! cached-text text)) value)))) (begin (invalidate!) missing)))))) (define (make-pairing-identity-reader state-dir) (make-pairing-file-reader (pairing-identity-path state-dir) decode-identity #f)) (define (make-pairing-records-reader state-dir) (make-pairing-file-reader (pairing-records-path state-dir) decode-records '())) ;; Safe for logs/status: deliberately reconstruct instead of subtracting ;; known secret names from a potentially extended record. (define (pairing-public-summary record) (if (pairing-identity-valid? record) #{ version: 1 key-id: (dict-ref record key-id: "") generation: (dict-ref record generation: 0) created: (dict-ref record created: 0) } #{ bot-id: (dict-ref record bot-id: "") transport-nick: (dict-ref record transport-nick: "") peer-key-id: (dict-ref record peer-key-id: "") peer-generation: (dict-ref record peer-generation: 0) revoked: (dict-ref record revoked: #t) }))))packages/slate-cli/src/slate-cli/persistent.sgladded
;;; Persistent local-node installation for Android/Termux and desktop Unix.;;;;;; This deliberately owns only the persistent local node lifecycle. It is;;; not a transport manager and does not register or dial remote nodes.(define-library (slate-cli persistent) (import (sigil core) (sigil string) (sigil fs) (sigil path) (sigil process) (sigil json) (slate-cli config) (slate-cli node-posture)) (export run-persistent-install! run-persistent-command! ;; Pure/generated seams used by the persistent-install tests. persistent-root-dir persistent-service-script persistent-control-script persistent-boot-script persistent-launcher-script persistent-openbsd-rc-script persistent-install-source persistent-app-url persistent-status-schema persistent-status-from-observations persistent-runtime-status persistent-fallback-status persistent-status-json) (begin (define slate-version local-node-version) (define (env-required name message) (let ((v (getenv name))) (if (and v (> (string-length v) 0)) v (begin (display (string-append "slate node: " message "\n")) (exit 2))))) (define (termux-home) (or (getenv "SLATE_PERSIST_ROOT") (env-required "HOME" "HOME is not set"))) (define (termux-prefix) (or (getenv "SLATE_TERMUX_PREFIX") (env-required "PREFIX" "--persistent is supported only in Termux (PREFIX is not set)"))) (define (termux-environment?) (or (getenv "TERMUX_VERSION") (getenv "SLATE_TERMUX_PREFIX"))) (define (openbsd-environment?) (or (getenv "SLATE_OPENBSD_RC") (file-exists? "/etc/rc.d/rc.subr"))) (define (root-dir) (path-join (termux-home) ".local" "share" "slate" "node")) (define (bin-dir) (path-join (termux-home) ".local" "bin")) (define (control-path) (path-join (bin-dir) "slate-service")) (define (service-dir) (path-join (termux-prefix) "var" "service" "slate")) (define (boot-path) (path-join (termux-home) ".termux" "boot" "20-slate")) (define (version-dir) (path-join (root-dir) "versions" slate-version)) (define (installed-binary) (path-join (version-dir) "slate")) ;; Stable CLI path. Version directories remain the atomic upgrade unit, ;; while shells and service definitions never need a release-specific path. (define (launcher-script) (string-append "#!/bin/sh\nset -eu\n" "ROOT=" (quoted (root-dir)) "\n" "version=$(cat \"$ROOT/active\")\n" "case \"$version\" in ''|*[!A-Za-z0-9._-]*) echo 'slate: invalid active version' >&2; exit 70;; esac\n" "binary=\"$ROOT/versions/$version/slate\"\n" "[ -x \"$binary\" ] || { echo \"slate: active binary is unavailable: $binary\" >&2; exit 70; }\n" "exec \"$binary\" \"$@\"\n")) (define (persistent-launcher-script) (launcher-script)) ;; OpenBSD has one system rc.d graph, but rc.subr can drop the daemon to an ;; ordinary login user. Launch through Slate's stable command so atomic ;; version switches need no service-file edit; pexp follows the executable ;; after the launcher execs the selected version. (define (persistent-openbsd-rc-script home user) (let ((stable (path-join home ".local" "bin" "slate")) (versions (path-join home ".local" "share" "slate" "node" "versions"))) (string-append "#!/bin/ksh\n\n" "daemon=" (quoted stable) "\n" "daemon_flags=\"--log-level info node run\"\n" "daemon_user=" (quoted user) "\n" "daemon_execdir=" (quoted home) "\n" "daemon_logger=\"daemon.info\"\n" "rc_bg=YES\n" "pexp=\"" versions "/.*/slate --log-level info node run\"\n\n" ". /etc/rc.d/rc.subr\n\n" "rc_cmd $1\n"))) (define (persistent-status-from-observations o) (let* ((installed (dict-ref o installed: #f)) (pid (dict-ref o pid: #f)) (pid-live (dict-ref o pid-live: #f)) (boot (dict-ref o boot-configured: #f)) (url (dict-ref o app-url: "https://useslate.org")) (platform-kind (dict-ref o platform-kind: "linux-desktop")) (environment (dict-ref o environment: "native")) (supervision-kind (dict-ref o supervision-kind: "none")) (autostart-posture (dict-ref o autostart-posture: (if boot "configured" "not-configured")))) #{ schema-version: 3 identity: (local-node-identity) capability-posture: (local-node-capability-posture) upgrade: (local-node-upgrade-posture platform-kind environment installed (dict-ref o latest-version: #f)) platform: #{ kind: platform-kind environment: environment } installation: #{ installed: installed posture: (if installed "installed" "not-installed") } supervision: #{ kind: supervision-kind available: (dict-ref o supervisor-available: #f) running: (dict-ref o supervisor-running: #f) } persistence: #{ installed: installed posture: (if installed "installed" "not-installed") } supervisor: #{ available: (dict-ref o supervisor-available: #f) running: (dict-ref o supervisor-running: #f) } process: #{ pid: pid live: pid-live pid-state: (cond ((not pid) "absent") (pid-live "exact") (else "stale")) } health: #{ healthy: (dict-ref o healthy: #f) url: "http://127.0.0.1:32640/node/health" } listener: #{ healthy: (dict-ref o healthy: #f) url: "http://127.0.0.1:32640/node/health" } app: #{ url: url selection: (cond ((string=? url "https://useslate.org") "production") ((string=? url "https://next.useslate.org") "staging") (else "custom")) } boot: #{ configured: boot } autostart: #{ configured: boot posture: autostart-posture } lifecycle: #{ state: (dict-ref o lifecycle-state: "missing") last-start: (dict-ref o last-start: #f) last-exit: (dict-ref o last-exit: #f) restart-count: (dict-ref o restart-count: 0) } wake-lock: #{ choice: (dict-ref o wake-lock: "disabled") } platform-details: (if (string=? environment "termux") #{ termux: #{ boot-configured: boot wake-lock: (dict-ref o wake-lock: "disabled") android-persistence-posture: (if boot "user-managed" "unknown") autostart: "unknown" background: "unknown" battery-exemption: "unknown" } } #{ desktop: #{ service-manager: supervision-kind autostart-posture: autostart-posture } }) android: #{ persistence-posture: (if (and (string=? environment "termux") boot) "user-managed" "unknown") autostart: "unknown" background: "unknown" battery-exemption: "unknown" } })) (define (persistent-status-schema) (persistent-status-from-observations #{})) ;; The CLI can be installed independently of Termux's generated control ;; script (notably when a desktop package is supervised by Shepherd). Its ;; read-only fallback must describe the host it is actually running on; ;; the absence of slate-service is not evidence that the host is Android. (define (persistent-fallback-status) (let ((termux? (termux-environment?))) (persistent-status-from-observations #{ platform-kind: (if termux? "android" "desktop") environment: (if termux? "termux" "native") supervision-kind: (if termux? "runit" "none") }))) (define (read-optional path) (and (file-exists? path) (guard (e (#t #f)) (string-trim (read-file-string path))))) (define (natural-or-false value) (and value (> (string-length value) 0) (let ((n (string->number value))) (and n (integer? n) (>= n 0) n)))) ;; A service manager declares itself to the child. Inferring supervision ;; from PPID or process names is racy and breaks across Shepherd versions. ;; Unknown values fail closed to the ordinary foreground posture. (define (desktop-supervision-kind) (let ((kind (getenv "SLATE_SUPERVISION"))) (and kind (member kind '("shepherd" "systemd" "launchd")) kind))) (define (desktop-autostart-configured?) (string=? (or (getenv "SLATE_AUTOSTART") "") "configured")) ;; Runtime view used by the loopback HTTP surface. Foreground desktop ;; nodes are healthy nodes even though they have no persistent supervisor; ;; Termux contributes its runit/boot vocabulary without shaping the common ;; contract. Persistent lifecycle files remain optional observations. (define (persistent-runtime-status healthy?) (let* ((termux? (or (getenv "TERMUX_VERSION") (getenv "SLATE_TERMUX_PREFIX"))) (desktop-supervision (and (not termux?) (desktop-supervision-kind))) (desktop-autostart (and desktop-supervision (desktop-autostart-configured?))) (home (or (getenv "SLATE_PERSIST_ROOT") (getenv "HOME"))) (root (and home (path-join home ".local" "share" "slate" "node"))) (life (and root (path-join root "lifecycle"))) (active (and root (read-optional (path-join root "active")))) (installed (and active (file-exists? (path-join root "versions" active "slate")))) (boot (and termux? home (file-exists? (path-join home ".termux" "boot" "20-slate")))) (pid-text (and life (read-optional (path-join life "child.pid")))) (pid (natural-or-false pid-text)) (pid-valid? (or (not pid-text) pid)) (last-start (and life (read-optional (path-join life "last-start-at")))) (last-reason (and life (read-optional (path-join life "last-exit-reason")))) (last-at (and life (read-optional (path-join life "last-exit-at")))) (last-observed (and life (read-optional (path-join life "last-exit-observed-at")))) (last-status-text (and life (read-optional (path-join life "last-exit-status")))) (last-status (natural-or-false last-status-text)) (restart-text (and life (read-optional (path-join life "restart-count")))) (restart-count (or (natural-or-false restart-text) 0)) (corrupt? (or (not pid-valid?) (and restart-text (not (natural-or-false restart-text))) (and last-status-text (not last-status)) (and last-reason (not (or last-at last-observed))))) (last-exit (and last-reason #{ at: last-at observed-at: last-observed status: last-status reason: last-reason })) (exact-child? (and pid (= pid (process-id))))) (persistent-status-from-observations #{ platform-kind: (if termux? "android" "desktop") environment: (if termux? "termux" "native") installed: installed supervision-kind: (cond (termux? "runit") (desktop-supervision desktop-supervision) (else "none")) supervisor-available: (if termux? (command-exists? "sv") (if desktop-supervision #t #f)) supervisor-running: (if termux? exact-child? (if desktop-supervision #t #f)) pid: (or pid (process-id)) pid-live: (if pid exact-child? #t) healthy: healthy? app-url: (or (and root (read-optional (path-join root "app-upstream"))) "https://useslate.org") boot-configured: (if termux? boot desktop-autostart) wake-lock: (if (and root (file-exists? (path-join root "wake-lock-enabled"))) "enabled" "disabled") autostart-posture: (if (if termux? boot desktop-autostart) "configured" "not-configured") lifecycle-state: (cond (corrupt? "corrupt") ((and life (file-exists? life)) "valid") (else "missing")) last-start: last-start last-exit: last-exit restart-count: restart-count }))) (define (persistent-status-json status) (json-encode status)) (define (persistent-root-dir) (root-dir)) (define (quoted s) ;; Generated paths come from HOME/PREFIX. Refuse the one byte that would ;; make single-quoted shell data executable rather than trying to be clever. (if (string-contains? s "'") (begin (display "slate node: HOME/PREFIX containing a quote is unsupported\n") (exit 2)) (string-append "'" s "'"))) (define (write-executable! path content) (ensure-directory (path-dirname path)) (let ((tmp (string-append path ".tmp"))) (write-file-string tmp content) (unless (= (process-run "chmod" "700" tmp) 0) (error "could not secure generated executable" tmp)) (rename-file tmp path))) ;; Native bundles already expose the platform-correct executable path ;; (including macOS, where /proc/self/exe does not exist). The override is ;; retained for package managers and for upgrading a running service from a ;; separately staged artifact. (define (persistent-install-source) (let ((override (getenv "SLATE_PERSIST_EXECUTABLE"))) (or (and override (> (string-length override) 0) override) (guard (e (#t #f)) (executable-path)) "/proc/self/exe"))) (define (copy-self!) (ensure-directory (version-dir)) (let* ((source (persistent-install-source)) (tmp (string-append (installed-binary) ".tmp"))) (unless (file-exists? source) (error "Slate executable is unavailable" source)) (guard (e (#t (error "could not install Slate binary" source tmp))) (copy-file source tmp)) ;; Some platform copy backends report success after a missing source; ;; never continue into chmod/rename with an absent temporary file. (unless (file-exists? tmp) (error "could not install Slate binary" source tmp)) ;; Android's copy primitive does not preserve the source mode (the ;; desktop implementation currently does), so make executability an ;; explicit, checked part of the atomic install on every platform. (unless (= (process-run "chmod" "700" tmp) 0) (error "could not secure Slate binary" tmp)) (rename-file tmp (installed-binary)) (write-file-string (string-append (root-dir) "/active.tmp") slate-version) (rename-file (string-append (root-dir) "/active.tmp") (path-join (root-dir) "active")))) (define (service-script) (string-append "#!" (termux-prefix) "/bin/sh\n" "set -u\nROOT=" (quoted (root-dir)) "\n" "PREFIX=" (quoted (termux-prefix)) "\n" "export XDG_STATE_HOME=\"$ROOT/state\" XDG_CACHE_HOME=\"$ROOT/cache\"\n" "export SSL_CERT_FILE=\"${SSL_CERT_FILE:-$PREFIX/etc/tls/cert.pem}\"\n" "LIFE=\"$ROOT/lifecycle\"\nmkdir -p \"$LIFE\"\n" "atomic() { p=$1; shift; t=\"$p.tmp.$$\"; printf '%s\\n' \"$*\" >\"$t\" && mv \"$t\" \"$p\"; }\n" "count=$(cat \"$LIFE/restart-count\" 2>/dev/null || printf 0)\ncase \"$count\" in ''|*[!0-9]*) count=0;; esac\n" "prior=$(cat \"$LIFE/child.pid\" 2>/dev/null || true)\n" "case \"$prior\" in ''|*[!0-9]*) ;; *) if ! kill -0 \"$prior\" 2>/dev/null; then count=$((count + 1)); atomic \"$LIFE/restart-count\" \"$count\"; atomic \"$LIFE/last-exit-observed-at\" \"$(date -Iseconds)\"; atomic \"$LIFE/last-exit-reason\" unclean-supervisor-recovery; rm -f \"$LIFE/last-exit-at\" \"$LIFE/last-exit-status\"; fi;; esac\n" "delay=1\nchild=\n" "stop_child() {\n" " trap - TERM INT HUP\n" " if [ -n \"$child\" ] && kill -0 \"$child\" 2>/dev/null; then kill \"$child\"; wait \"$child\" 2>/dev/null; rc=$?; atomic \"$LIFE/last-exit-at\" \"$(date -Iseconds)\"; atomic \"$LIFE/last-exit-status\" \"$rc\"; atomic \"$LIFE/last-exit-reason\" stop-requested; rm -f \"$LIFE/last-exit-observed-at\"; fi\n" " rm -f \"$LIFE/child.pid\"\n" " exit 0\n" "}\n" "trap stop_child TERM INT HUP\n" "while :; do\n" " version=$(cat \"$ROOT/active\") || exit 70\n" " upstream=$(cat \"$ROOT/app-upstream\" 2>/dev/null || printf '%s' https://useslate.org)\n" " \"$ROOT/versions/$version/slate\" node run --host 127.0.0.1 --port 32640 --app-upstream \"$upstream\" --log \"$ROOT/log/slate.log\" >>\"$ROOT/log/service-output.log\" 2>&1 &\n" " child=$!\n" " atomic \"$LIFE/child.pid\" \"$child\"\n" " atomic \"$LIFE/last-start-at\" \"$(date -Iseconds)\"\n" " wait \"$child\"; rc=$?; child=\n" " rm -f \"$LIFE/child.pid\"\n" " atomic \"$LIFE/last-exit-at\" \"$(date -Iseconds)\"\n" " atomic \"$LIFE/last-exit-status\" \"$rc\"\n" " atomic \"$LIFE/last-exit-reason\" exited\n" " rm -f \"$LIFE/last-exit-observed-at\"\n" " count=$((count + 1)); atomic \"$LIFE/restart-count\" \"$count\"\n" " printf '%s exit=%s restart-delay=%s\\n' \"$(date -Iseconds)\" \"$rc\" \"$delay\" >>\"$ROOT/log/supervisor.log\"\n" " sleep \"$delay\"\n" " if [ \"$delay\" -lt 30 ]; then delay=$((delay * 2)); [ \"$delay\" -le 30 ] || delay=30; fi\n" "done\n")) (define (control-script) (string-append "#!" (termux-prefix) "/bin/sh\nset -u\n" "ROOT=" (quoted (root-dir)) "\nPREFIX=" (quoted (termux-prefix)) "\n" "export SVDIR=\"$PREFIX/var/service\"\n" "SERVICE=slate\nURL=http://127.0.0.1:32640/node/health\n" "health() { curl -fsS --max-time 2 \"$URL\" >/dev/null 2>&1; }\n" "running() { sv status \"$SERVICE\" 2>/dev/null | grep -q '^run:'; }\n" "json_string() { printf '%s' \"$1\" | tr '\\r\\n' ' ' | sed 's/[\\\\\"]/\\\\&/g'; }\n" "status_json() {\n" " installed=false; active=$(cat \"$ROOT/active\" 2>/dev/null || true); [ -n \"$active\" ] && [ -x \"$ROOT/versions/$active/slate\" ] && installed=true\n" " available=false; command -v sv >/dev/null 2>&1 && [ -x \"$PREFIX/var/service/$SERVICE/run\" ] && available=true\n" " supervised=false; [ \"$available\" = true ] && running && supervised=true\n" " pid=null; live=false; pidstate=absent; life=missing; corrupt=false\n" " [ -d \"$ROOT/lifecycle\" ] && life=valid\n" " if [ -f \"$ROOT/lifecycle/child.pid\" ]; then p=$(cat \"$ROOT/lifecycle/child.pid\" 2>/dev/null); case \"$p\" in ''|*[!0-9]*) pidstate=invalid; corrupt=true;; *) pid=$p; if kill -0 \"$p\" 2>/dev/null; then live=true; pidstate=exact; else pidstate=stale; fi;; esac; fi\n" " healthy=false; health && healthy=true\n" " upstream=$(cat \"$ROOT/app-upstream\" 2>/dev/null || printf '%s' https://useslate.org); selection=custom; [ \"$upstream\" = https://useslate.org ] && selection=production; [ \"$upstream\" = https://next.useslate.org ] && selection=staging\n" " boot=false; [ -x " (quoted (boot-path)) " ] && boot=true\n" " start=null; if [ -f \"$ROOT/lifecycle/last-start-at\" ]; then v=$(cat \"$ROOT/lifecycle/last-start-at\"); [ -n \"$v\" ] && start=\"\\\"$(json_string \"$v\")\\\"\" || corrupt=true; fi\n" " restart=0; if [ -f \"$ROOT/lifecycle/restart-count\" ]; then restart=$(cat \"$ROOT/lifecycle/restart-count\"); case \"$restart\" in ''|*[!0-9]*) restart=0; corrupt=true;; esac; fi\n" " lastexit=null; if [ -f \"$ROOT/lifecycle/last-exit-reason\" ]; then reason=$(cat \"$ROOT/lifecycle/last-exit-reason\" 2>/dev/null); at=null; observed=null; rc=null; if [ -f \"$ROOT/lifecycle/last-exit-at\" ]; then v=$(cat \"$ROOT/lifecycle/last-exit-at\"); [ -n \"$v\" ] && at=\"\\\"$(json_string \"$v\")\\\"\" || corrupt=true; fi; if [ -f \"$ROOT/lifecycle/last-exit-observed-at\" ]; then v=$(cat \"$ROOT/lifecycle/last-exit-observed-at\"); [ -n \"$v\" ] && observed=\"\\\"$(json_string \"$v\")\\\"\" || corrupt=true; fi; if [ -f \"$ROOT/lifecycle/last-exit-status\" ]; then rc=$(cat \"$ROOT/lifecycle/last-exit-status\"); case \"$rc\" in ''|*[!0-9]*) rc=null; corrupt=true;; esac; fi; [ -n \"$reason\" ] && { [ \"$at\" != null ] || [ \"$observed\" != null ]; } && lastexit=\"{\\\"at\\\":$at,\\\"observed-at\\\":$observed,\\\"status\\\":$rc,\\\"reason\\\":\\\"$(json_string \"$reason\")\\\"}\" || corrupt=true; fi\n" " [ \"$corrupt\" = true ] && life=corrupt\n" " wake=disabled; [ -f \"$ROOT/wake-lock-enabled\" ] && wake=enabled\n" " posture=unknown; [ \"$boot\" = true ] && posture=user-managed\n" " [ \"$installed\" = true ] && installposture=installed || installposture=not-installed\n" " printf '{\"schema-version\":3,\"identity\":{\"role\":\"local-node\",\"scope\":\"local-device\"},\"capability-posture\":{\"resource\":{\"available\":true,\"operations\":[\"fs\",\"proc\",\"sys\"]},\"session\":{\"available\":true,\"operations\":[\"terminal\"]},\"agent\":{\"available\":true,\"operations\":[\"agent\"]},\"renderer\":{\"available\":true,\"same-origin\":true},\"upgrade\":{\"available\":true,\"operations\":[\"check\"],\"apply-available\":false},\"remote-transport-manager\":{\"available\":false,\"reason\":\"not-implemented\"}},\"upgrade\":{\"current-version\":\"" slate-version "\",\"available-version\":false,\"state\":\"not-checked\",\"check\":{\"available\":true,\"endpoint\":\"/node/api/version\"},\"apply\":{\"available\":false,\"reason\":\"not-implemented\"},\"rollback\":{\"available\":false,\"reason\":\"not-implemented\"},\"installation\":{\"strategy\":\"atomic-version-directory\",\"platform\":\"android\",\"environment\":\"termux\"}},\"platform\":{\"kind\":\"android\",\"environment\":\"termux\"},\"installation\":{\"installed\":%s,\"posture\":\"%s\"},\"supervision\":{\"kind\":\"runit\",\"available\":%s,\"running\":%s},\"persistence\":{\"installed\":%s,\"posture\":\"%s\"},\"supervisor\":{\"available\":%s,\"running\":%s},\"process\":{\"pid\":%s,\"live\":%s,\"pid-state\":\"%s\"},\"health\":{\"healthy\":%s,\"url\":\"%s\"},\"listener\":{\"healthy\":%s,\"url\":\"%s\"},\"app\":{\"url\":\"%s\",\"selection\":\"%s\"},\"boot\":{\"configured\":%s},\"autostart\":{\"configured\":%s,\"posture\":\"%s\"},\"lifecycle\":{\"state\":\"%s\",\"last-start\":%s,\"last-exit\":%s,\"restart-count\":%s},\"wake-lock\":{\"choice\":\"%s\"},\"platform-details\":{\"termux\":{\"boot-configured\":%s,\"wake-lock\":\"%s\",\"android-persistence-posture\":\"%s\",\"autostart\":\"unknown\",\"background\":\"unknown\",\"battery-exemption\":\"unknown\"}},\"android\":{\"persistence-posture\":\"%s\",\"autostart\":\"unknown\",\"background\":\"unknown\",\"battery-exemption\":\"unknown\"}}\\n' \"$installed\" \"$installposture\" \"$available\" \"$supervised\" \"$installed\" \"$installposture\" \"$available\" \"$supervised\" \"$pid\" \"$live\" \"$pidstate\" \"$healthy\" \"$URL\" \"$healthy\" \"$URL\" \"$(json_string \"$upstream\")\" \"$selection\" \"$boot\" \"$boot\" \"$([ \"$boot\" = true ] && printf configured || printf not-configured)\" \"$life\" \"$start\" \"$lastexit\" \"$restart\" \"$wake\" \"$boot\" \"$wake\" \"$posture\" \"$posture\"\n" "}\n" "case \"${1:-}\" in\n" " start) sv up \"$SERVICE\" || exit $?; i=0; while [ $i -lt 30 ]; do health && exit 0; i=$((i+1)); sleep 1; done; echo 'slate-service: loopback health check timed out' >&2; exit 1;;\n" " stop) sv down \"$SERVICE\" || exit $?; i=0; while running || [ -f \"$ROOT/lifecycle/child.pid\" ]; do i=$((i+1)); [ \"$i\" -lt 30 ] || { echo 'slate-service: supervisor stop timed out' >&2; exit 1; }; sleep 1; done;;\n" " restart) sv restart \"$SERVICE\" || exit $?; \"$0\" start;;\n" " status) sv status \"$SERVICE\"; if health; then echo 'loopback health: ok'; else echo 'loopback health: unavailable'; exit 1; fi;;\n" " status-json) status_json;;\n" " health) health;;\n" " running) running;;\n" " logs) tail -n 200 \"$ROOT/log/slate.log\" \"$ROOT/log/supervisor.log\" \"$ROOT/log/service-output.log\" 2>/dev/null;;\n" " *) echo 'usage: slate-service start|stop|restart|status|logs' >&2; exit 2;;\n" "esac\n")) (define (boot-script) (string-append "#!" (termux-prefix) "/bin/sh\n" "# Slate node: start supervision silently; never open a browser at boot.\n" "ROOT=" (quoted (root-dir)) "\n" "mkdir -p \"$ROOT/log\"\n" "printf '%s boot-invoked\\n' \"$(date -Iseconds)\" >>\"$ROOT/log/boot.log\"\n" ". " (termux-prefix) "/etc/profile.d/start-services.sh\n" "command -v termux-wake-lock >/dev/null 2>&1 && [ -f \"$HOME/.local/share/slate/node/wake-lock-enabled\" ] && termux-wake-lock\n" "i=0\n" "while ! sv status slate >/dev/null 2>&1; do\n" " i=$((i + 1))\n" " if [ \"$i\" -ge 30 ]; then printf '%s runit-readiness-timeout\\n' \"$(date -Iseconds)\" >>\"$ROOT/log/boot.log\"; exit 1; fi\n" " sleep 1\n" "done\n" "$HOME/.local/bin/slate-service start >>\"$ROOT/log/boot.log\" 2>&1\n")) (define (persistent-service-script) (service-script)) (define (persistent-control-script) (control-script)) (define (persistent-boot-script) (boot-script)) (define (persistent-app-url requested) (let ((url (cond ((string=? requested "production") "https://useslate.org") ((string=? requested "staging") "https://next.useslate.org") (else requested)))) (and (>= (string-length url) 8) (string=? (substring url 0 8) "https://") url))) (define (persistent-install-opts args) (let loop ((rest args) (persistent? #f) (name #f)) (cond ((null? rest) (and persistent? #{ name: name })) ((string=? (car rest) "--persistent") (and (not persistent?) (loop (cdr rest) #t name))) ((and (string=? (car rest) "--name") (pair? (cdr rest)) (not name)) (loop (cddr rest) persistent? (cadr rest))) (else #f)))) (define (run-persistent-install! args) (let ((opts (persistent-install-opts args))) (unless opts (display "usage: slate node install --persistent [--name <node-name>]\n") (exit 2)) (for-each ensure-directory (list (root-dir) (path-join (root-dir) "log") (path-join (root-dir) "state") (path-join (root-dir) "cache"))) (let ((name (dict-ref opts name: #f)) ;; Termux deliberately contains all daemon state below its owned ;; node root. Desktop nodes retain the ordinary XDG state location ;; so an existing enrollment and pairings survive installation. (state-dir (if (termux-environment?) (path-join (root-dir) "state" "slate") (config-state-dir (make-config))))) (unless (or (not name) (valid-local-node-name? name)) (display "slate node install: invalid node name\n") (exit 2)) (local-node-ensure-name! state-dir name)) (copy-self!) (write-executable! (path-join (bin-dir) "slate") (launcher-script)) (if (termux-environment?) (begin (termux-prefix) (write-executable! (path-join (service-dir) "run") (service-script)) (write-executable! (control-path) (control-script)) (write-executable! (boot-path) (boot-script)) (unless (and (command-exists? "sv") (command-exists? "curl")) (display "Slate was installed, but termux-services and curl are required. Run: pkg install termux-services curl\n") (exit 1)) ;; An idempotent reinstall is also an upgrade: the old inode may ;; still be running, so restart an active service and merely start ;; a stopped/new one. (let ((verb (if (= (process-run (control-path) "running") 0) "restart" "start"))) (unless (= (process-run (control-path) verb) 0) (exit 1))) (display "Slate node is supervised and healthy on http://127.0.0.1:32640/.\n") (display "Termux:Boot: install it, open it once, and allow background execution. The boot entry is ready.\n") (display "Android may still stop Termux; consider a battery-optimization exemption. Wake lock remains opt-in.\n") (when (command-exists? "termux-open-url") (process-run "termux-open-url" "http://127.0.0.1:32640/?install=1"))) (if (openbsd-environment?) (let* ((user (or (getenv "USER") (getenv "LOGNAME") "")) (staged (path-join (root-dir) "slate_node.rc"))) (when (or (string=? user "") (string=? user "root")) (display "slate node install: run OpenBSD nodes from a non-root login account\n") (exit 2)) (unless (command-exists? "doas") (display "slate node install: OpenBSD service installation requires doas\n") (exit 1)) (write-executable! staged (persistent-openbsd-rc-script (termux-home) user)) (unless (= (process-run "doas" "install" "-o" "root" "-g" "wheel" "-m" "555" staged "/etc/rc.d/slate_node") 0) (error "could not install OpenBSD rc.d service")) (unless (= (process-run "doas" "rcctl" "enable" "slate_node") 0) (error "could not enable OpenBSD rc.d service")) ;; Idempotent installation is an upgrade: restart when already ;; running, otherwise perform the first start. (let ((verb (if (= (process-run "doas" "rcctl" "check" "slate_node") 0) "restart" "start"))) (unless (= (process-run "doas" "rcctl" verb "slate_node") 0) (error "could not start OpenBSD rc.d service"))) (display "Slate node is installed as OpenBSD service slate_node.\n")) (begin ;; Guix Home is declarative. Installation owns the immutable-ish ;; version directory and active marker, but never rewrites or ;; reconfigures the user's service graph behind their back. (display (string-append "Slate node binary installed at " (installed-binary) "\n")) (display (string-append "Stable Slate command installed at " (path-join (bin-dir) "slate") "\n"))))))) (define (run-persistent-command! verb args) (unless (or (null? args) (and (string=? verb "app") (= (length args) 1)) (and (string=? verb "status") (equal? args '("--json")))) (display (string-append "usage: slate node " verb "\n")) (exit 2)) (cond ((string=? verb "app") (unless (file-exists? (control-path)) (display "slate node: persistent node is not installed\n") (exit 1)) (when (= (process-run (control-path) "running") 0) (display "slate node app: stop the node before changing its served app\n") (exit 1)) (let ((url (persistent-app-url (car args)))) (unless url (display "slate node app: expected staging, production, or an https:// URL\n") (exit 2)) (let ((tmp (string-append (root-dir) "/app-upstream.tmp"))) (write-file-string tmp url) (rename-file tmp (path-join (root-dir) "app-upstream")))Showing the first 500 of 528 diff lines for this file. This diff is INCOMPLETE; read the file or clone the repository for the rest.
packages/slate-cli/src/slate-cli/proxy.sglmodified
(slate-cli config) (slate-cli cache) (slate-cli httpfetch) (slate-cli listen)) (slate-cli listen) (slate-cli node-posture)) (export ;; main's HTTP router calls this for every non-/pair/ path. (begin ;; The CLI's own version + wire protocol (design §2.1). Kept in step ;; with package.sgl by hand for now; a build-stamp is a follow-up. (define cli-version "0.1.0") ;; The CLI's own version + wire protocol (design §2.1). (define cli-version local-node-version) (define protocol-version 1) (define release-metadata-url "https://useslate.org/releases/cli/latest.json") ;; ================================================================ ;; HTTP router for the app surface (everything main didn't send to ;;; The version dict. `cli-latest` is present only when upstream is ;;; reachable (best-effort, short-TTL cached); absent offline, so the ;;; endpoint degrades honestly. (define (version-info daemon) (define (version-info daemon . fetch-arg) (let* ((config (dict-ref daemon config: #f)) (cache (make-cache (config-cache-dir config))) (base #{ cli-version: cli-version protocol: protocol-version pwa-version-seen: (cache-version-seen cache) pwa-version-cached: (cache-version-cached cache) }) (latest (cli-latest config))) ;; Tests inject the release fetch so their offline claim cannot ;; silently reach production. Normal callers use the TTL cache. (latest (if (null? fetch-arg) (cli-latest) (fetch-cli-latest (car fetch-arg))))) (if latest (dict-set base cli-latest: latest) base))) ;; ---- cli-latest: <upstream>/release/latest.json, TTL-cached ---- ;; ---- cli-latest: trusted Slate release metadata, TTL-cached ---- ;; A module-level cell is fine: one daemon per process, one upstream. (define latest-cell (vector #f 0)) ; (version . fetched-at) (define latest-ttl-secs 300) (define (cli-latest config) (define (cli-latest) (let ((now (current-second)) (cached-v (vector-ref latest-cell 0)) (cached-at (vector-ref latest-cell 1))) (cond ((and cached-v (< (- now cached-at) latest-ttl-secs)) cached-v) (else (refresh-cli-latest config now))))) (else (refresh-cli-latest now))))) (define (refresh-cli-latest config now) (let* ((upstream (config-app-upstream config)) (url (string-append (strip-trailing-slash upstream) "/release/latest.json")) (result (http-fetch 'GET url '()))) (define (fetch-cli-latest fetch-fn) (let ((result (fetch-fn 'GET release-metadata-url '()))) (cond ((and result (= (fetch-status result) 200)) (let ((v (guard (e (#t #f)) (let ((j (json-decode (utf8->string (fetch-body result))))) (and (dict? j) (dict-ref j version: #f)))))) (when (string? v) (vector-set! latest-cell 0 v) (vector-set! latest-cell 1 now)) (and (dict? j) (dict-ref j latest: #f)))))) (and (string? v) v))) (else ;; unreachable: keep any prior value but report absent when we ;; have never learned one (honest offline behavior). (vector-ref latest-cell 0))))) (else #f)))) (define (refresh-cli-latest now) (let ((v (fetch-cli-latest http-fetch))) (when v (vector-set! latest-cell 0 v) (vector-set! latest-cell 1 now)) ;; Unreachable: keep a previously learned value, but report absent ;; when this process has never learned one. (or v (vector-ref latest-cell 0)))) ;; ================================================================ ;; Byte-exact HTTP response (send-http-response! in listen ispackages/slate-cli/src/slate-cli/registry.sglmodified
registry-remove! registry-entries entry-origin entry-metadata registry-close-matching! close-revoked! origin-still-paired?) ;; the connection can remove exactly its own on disconnect. (define (make-conn-registry) (vector '())) (define (registry-add! reg origin close-thunk) (let ((entry (vector origin close-thunk))) (define (registry-add! reg origin close-thunk . metadata) (let ((entry (vector origin close-thunk (if (pair? metadata) (car metadata) #{})))) (vector-set! reg 0 (cons entry (vector-ref reg 0))) entry)) (define (registry-entries reg) (vector-ref reg 0)) (define (entry-origin e) (vector-ref e 0)) (define (entry-metadata e) (if (> (vector-length e) 2) (vector-ref e 2) #{})) (define (entry-close! e) ((vector-ref e 1))) (define (registry-close-matching! reg predicate) (let ((targets (filter predicate (registry-entries reg)))) (for-each (lambda (entry) (registry-remove! reg entry) (guard (e (#t #f)) (entry-close! entry))) targets) (length targets))) ;;; Does `origin` still have a pairing in the (freshly reloaded) ;;; store? (define (origin-still-paired? store origin)packages/slate-cli/src/slate-cli/resource/transfer.sgladded
;;; (slate-cli resource transfer) — bounded multi-frame Resource transfers.;;;;;; This module owns no sockets or files. It is the shared protocol decision;;; layer used by the direct listener and relay holder: frames may arrive over;;; either carrier, but ordering, credit, completion, and cancellation must be;;; identical. Transport adapters perform I/O only after a transition returns;;; `accepted`.(define-library (slate-cli resource transfer) (import (sigil core)) (export transfer-chunk-bytes transfer-initial-credit make-transfer-registry transfer-count transfer-ids transfer-ref transfer-open! transfer-grant! transfer-batch! transfer-end! transfer-cancel! transfer-remove!) (begin ;; Deliberately below the 4 MiB WebSocket ceiling and aligned with the ;; listener's bounded read size. Credit is measured in payload bytes. (define transfer-chunk-bytes 65536) (define transfer-initial-credit (* transfer-chunk-bytes 4)) (define max-u32 4294967295) ;; Registry slots: entries. Entry fields are intentionally non-secret and ;; safe to expose to focused tests/status summaries. (define (make-transfer-registry) (vector '())) (define (entries registry) (vector-ref registry 0)) (define (transfer-count registry) (length (entries registry))) (define (transfer-ids registry) (map (lambda (entry) (dict-ref entry id: #f)) (entries registry))) (define (valid-id? id) (and (integer? id) (>= id 0) (<= id max-u32))) (define (transfer-ref registry id) (let loop ((rest (entries registry))) (and (pair? rest) (let ((entry (car rest))) (if (equal? id (dict-ref entry id: #f)) entry (loop (cdr rest))))))) (define (replace! registry id replacement) (let ((found #f)) (vector-set! registry 0 (map (lambda (entry) (if (equal? id (dict-ref entry id: #f)) (begin (set! found #t) replacement) entry)) (entries registry))) (and found replacement))) (define (transfer-remove! registry id) (let ((old (transfer-ref registry id))) (when old (vector-set! registry 0 (filter (lambda (entry) (not (equal? id (dict-ref entry id: #f)))) (entries registry)))) old)) ;;; Open one transfer. DIRECTION is `send` or `receive` from this endpoint's ;;; perspective. EXPECTED-SIZE may be #f when not known in advance. (define (transfer-open! registry id direction expected-size . metadata) (cond ((not (valid-id? id)) #{ status: 'refused reason: 'invalid-id }) ((not (member direction '(send receive))) #{ status: 'refused reason: 'invalid-direction }) ((and expected-size (not (and (integer? expected-size) (>= expected-size 0)))) #{ status: 'refused reason: 'invalid-size }) ((transfer-ref registry id) #{ status: 'refused reason: 'duplicate-id }) (else (let* ((meta (if (pair? metadata) (car metadata) #{})) (entry #{ id: id direction: direction state: 'active next-seq: 0 bytes: 0 credit: 0 expected-size: expected-size metadata: meta })) (vector-set! registry 0 (cons entry (entries registry))) entry)))) ;;; Grant additional payload bytes. Overflow and grants to terminal state ;;; are refused rather than wrapping the window. (define (transfer-grant! registry id amount) (let ((entry (transfer-ref registry id))) (cond ((not entry) #{ status: 'refused reason: 'unknown-id }) ((not (eq? (dict-ref entry state: #f) 'active)) #{ status: 'refused reason: 'not-active }) ((not (and (integer? amount) (> amount 0) (<= amount max-u32))) #{ status: 'refused reason: 'invalid-credit }) ((> (+ (dict-ref entry credit: 0) amount) max-u32) #{ status: 'refused reason: 'credit-overflow }) (else (replace! registry id (dict-set entry credit: (+ (dict-ref entry credit: 0) amount))))))) ;;; Accept one ordered chunk. The transport frame id correlates the ;;; transfer; SEQ lives in the batch payload header and starts at zero. (define (transfer-batch! registry id seq payload) (let ((entry (transfer-ref registry id))) (cond ((not entry) #{ status: 'refused reason: 'unknown-id }) ((not (eq? (dict-ref entry state: #f) 'active)) #{ status: 'refused reason: 'not-active }) ((not (bytevector? payload)) #{ status: 'refused reason: 'invalid-payload }) ((not (and (integer? seq) (>= seq 0))) #{ status: 'refused reason: 'invalid-sequence }) ((not (= seq (dict-ref entry next-seq: 0))) #{ status: 'refused reason: 'out-of-order expected: (dict-ref entry next-seq: 0) }) ((> (bytevector-length payload) transfer-chunk-bytes) #{ status: 'refused reason: 'chunk-too-large }) ((> (bytevector-length payload) (dict-ref entry credit: 0)) #{ status: 'refused reason: 'credit-exhausted }) ((let ((expected (dict-ref entry expected-size: #f))) (and expected (> (+ (dict-ref entry bytes: 0) (bytevector-length payload)) expected))) #{ status: 'refused reason: 'size-overrun }) (else (let ((updated (dict-set (dict-set (dict-set entry next-seq: (+ seq 1)) bytes: (+ (dict-ref entry bytes: 0) (bytevector-length payload))) credit: (- (dict-ref entry credit: 0) (bytevector-length payload))))) (replace! registry id updated) #{ status: 'accepted id: id seq: seq bytes: (bytevector-length payload) total: (dict-ref updated bytes: 0) }))))) ;;; END names the next sequence number, so a missing final batch cannot be ;;; mistaken for success. The entry remains terminal until transport glue ;;; has committed/closed its resource and explicitly removes it. (define (transfer-end! registry id next-seq total) (let ((entry (transfer-ref registry id))) (cond ((not entry) #{ status: 'refused reason: 'unknown-id }) ((not (eq? (dict-ref entry state: #f) 'active)) #{ status: 'refused reason: 'not-active }) ((or (not (integer? next-seq)) (not (= next-seq (dict-ref entry next-seq: 0)))) #{ status: 'refused reason: 'incomplete }) ((or (not (integer? total)) (not (= total (dict-ref entry bytes: 0)))) #{ status: 'refused reason: 'size-mismatch }) ((let ((expected (dict-ref entry expected-size: #f))) (and expected (not (= expected total)))) #{ status: 'refused reason: 'size-mismatch }) (else (replace! registry id (dict-set entry state: 'complete)) #{ status: 'complete id: id bytes: total })))) ;;; Cancellation is idempotent for an existing entry. The adapter gets the ;;; old metadata back so it can close/delete the owned temporary resource. (define (transfer-cancel! registry id reason) (let ((entry (transfer-remove! registry id))) (if entry #{ status: 'cancelled id: id reason: reason entry: entry } #{ status: 'refused reason: 'unknown-id }))) ))packages/slate-cli/src/slate-cli/resource/transfer/connection.sgladded
;;; (slate-cli resource transfer connection) — shared listener/relay adapter.(define-library (slate-cli resource transfer connection) (import (sigil core) (slate-cli listen) (slate-cli wire) (slate-cli resource transfer file)) (export resource-transfer-open! resource-transfer-frame! resource-transfer-frame? resource-transfer-close!) (begin (define manager-key "resource-transfers") (define (manager conn) (or (conn-state-get conn manager-key #f) (let ((created (make-file-transfer-manager))) (conn-state-set! conn manager-key created) created))) (define (resource-transfer-frame? frame) (and frame (member (stream-frame-type frame) (list frame-batch frame-end frame-cancel frame-transfer-credit)))) (define (send-error! conn id reason) (ws-send-control! conn #{ t: "err" id: id code: "transfer-refused" message: (if (symbol? reason) (symbol->string reason) "transfer refused") })) (define (send-open! conn transfer result) (let ((id (dict-ref transfer id: #f))) (if (not (eq? (dict-ref result status: #f) 'ready)) (send-error! conn id (dict-ref result reason: 'open-failed)) (begin (ws-send-control! conn #{ t: "ok" id: id transfer: #{ direction: (symbol->string (dict-ref result direction: 'unknown)) size: (dict-ref result size: 0) chunk-bytes: (dict-ref result chunk-bytes: 0) } }) (let ((credit (dict-ref result credit: #f))) (when credit (ws-send-binary! conn (encode-transfer-credit id credit)))))))) (define (resource-transfer-open! conn grants transfer) (let* ((direction (dict-ref transfer direction: #f)) (result (cond ((eq? direction 'upload) (file-upload-open! (manager conn) grants (dict-ref transfer id: #f) (dict-ref transfer path: "") (dict-ref transfer size: #f))) ((eq? direction 'download) (file-download-open! (manager conn) grants (dict-ref transfer id: #f) (dict-ref transfer path: ""))) (else #{ status: 'refused reason: 'invalid-direction })))) (send-open! conn transfer result) result)) (define (send-frames! conn frames) (for-each (lambda (bytes) (ws-send-binary! conn bytes)) frames)) (define (resource-transfer-frame! conn frame) (let* ((id (stream-frame-id frame)) (type (stream-frame-type frame)) (m (manager conn)) (result (cond ((= type frame-batch) (file-upload-batch! m frame)) ((= type frame-end) (file-transfer-end! m frame)) ((= type frame-transfer-credit) (file-download-credit! m frame)) ((= type frame-cancel) (file-transfer-cancel! m id 'peer-cancelled)) (else #{ status: 'refused reason: 'unknown-frame })))) (let ((credit (dict-ref result credit-frame: #f))) (when credit (ws-send-binary! conn credit))) (send-frames! conn (dict-ref result frames: '())) (cond ((and (= type frame-end) (eq? (dict-ref result status: #f) 'complete)) (ws-send-control! conn #{ t: "ok" id: id result: #{ path: (dict-ref result path: "") bytes: (dict-ref result bytes: 0) } })) ((and (= type frame-cancel) (eq? (dict-ref result status: #f) 'cancelled)) (ws-send-control! conn #{ t: "ok" id: id result: #{ cancelled: #t } })) ((eq? (dict-ref result status: #f) 'refused) (send-error! conn id (dict-ref result reason: 'transfer-refused)))) result)) (define (resource-transfer-close! conn reason) (let ((m (conn-state-get conn manager-key #f))) (if m (file-transfer-close-all! m reason) 0))) ))packages/slate-cli/src/slate-cli/resource/transfer/file.sgladded
;;; (slate-cli resource transfer file) — streaming file lifecycle.;;;;;; Authorization happens once, before a port is opened. Uploads write a unique;;; sibling and publish only after an exact END, so cancellation or transport;;; loss cannot expose partial destination content. The caller owns transport;;;; this module returns ordinary node frames for it to send.(define-library (slate-cli resource transfer file) (import (sigil core) (sigil io) (sigil fs) (sigil crypto) (sigil system grant) (sigil system fs) (slate-cli resource transfer) (slate-cli wire)) (export make-file-transfer-manager file-transfer-count file-upload-open! file-upload-batch! file-transfer-end! file-download-open! file-download-credit! file-transfer-cancel! file-transfer-close-all!) (begin ;; Manager slot 0 is the protocol registry. Keeping file resources in entry ;; metadata makes one removal return everything cleanup needs. (define (make-file-transfer-manager) (vector (make-transfer-registry))) (define (registry manager) (vector-ref manager 0)) (define (file-transfer-count manager) (transfer-count (registry manager))) ;; A sibling temp keeps the final rename on the destination filesystem. ;; The random suffix prevents concurrent connections with equal request ids ;; from sharing or truncating one another's staging path. (define (upload-temp-path target) (string-append target ".slate-transfer-" (base64url-encode (random-bytes 16)) ".tmp")) (define (active-entry manager id) (transfer-ref (registry manager) id)) (define (entry-meta entry) (and entry (dict-ref entry metadata: #f))) (define (cleanup-entry! entry publish?) (let* ((meta (entry-meta entry)) (port (and meta (dict-ref meta port: #f))) (temp (and meta (dict-ref meta temporary: #f))) (target (and meta (dict-ref meta target: #f)))) (when port (guard (e (#t #f)) (close-port port))) (cond ((and publish? temp target) (rename-file temp target)) (temp (guard (e (#t #f)) (delete-file temp)))))) (define (file-upload-open! manager grants id path size) (guard (e (#t #{ status: 'refused reason: 'open-failed })) (grant-assert! grants 'fs-write path) (unless (and (integer? size) (>= size 0) (<= size 4294967295)) (error "invalid transfer size")) (let* ((temp (upload-temp-path path)) (port (open-binary-output-file temp)) (opened (transfer-open! (registry manager) id 'receive size #{ kind: 'upload target: path temporary: temp port: port }))) (if (eq? (dict-ref opened status: #f) 'refused) (begin (close-port port) (delete-file temp) opened) (begin (transfer-grant! (registry manager) id transfer-initial-credit) #{ status: 'ready id: id direction: 'upload size: size chunk-bytes: transfer-chunk-bytes credit: transfer-initial-credit }))))) (define (file-upload-batch! manager frame) (let* ((decoded (decode-transfer-batch frame)) (id (and decoded (dict-ref decoded id: #f))) (entry (and id (active-entry manager id))) (meta (entry-meta entry))) (if (not (and decoded entry meta (eq? (dict-ref meta kind: #f) 'upload))) #{ status: 'refused reason: 'unknown-id } (let* ((bytes (dict-ref decoded bytes: #f)) (accepted (transfer-batch! (registry manager) id (dict-ref decoded seq: #f) bytes))) (if (not (eq? (dict-ref accepted status: #f) 'accepted)) accepted (guard (e (#t (file-transfer-cancel! manager id 'write-failed) #{ status: 'refused reason: 'write-failed })) (write-bytevector bytes (dict-ref meta port: #f)) ;; Replenish only bytes successfully handed to the file. (transfer-grant! (registry manager) id (bytevector-length bytes)) #{ status: 'accepted id: id progress: (dict-ref accepted total: 0) credit-frame: (encode-transfer-credit id (bytevector-length bytes)) })))))) (define (file-download-open! manager grants id path) (guard (e (#t #{ status: 'refused reason: 'open-failed })) (let* ((stat (fs-stat grants path)) (size (dict-ref stat size: -1))) (unless (and (integer? size) (>= size 0) (<= size 4294967295)) (error "file is outside the v1 transfer size boundary")) (let* ((port (open-binary-input-file path)) (opened (transfer-open! (registry manager) id 'send size #{ kind: 'download target: path port: port }))) (if (eq? (dict-ref opened status: #f) 'refused) (begin (close-port port) opened) #{ status: 'ready id: id direction: 'download size: size chunk-bytes: transfer-chunk-bytes }))))) ;; Drain at most the newly available credit. The returned frame list is ;; bounded by the caller's grant and is normally four 64 KiB frames. (define (file-download-credit! manager credit-frame) (let* ((decoded (decode-transfer-credit credit-frame)) (id (and decoded (dict-ref decoded id: #f))) (entry (and id (active-entry manager id))) (meta (entry-meta entry))) (if (not (and decoded entry meta (eq? (dict-ref meta kind: #f) 'download))) #{ status: 'refused reason: 'unknown-id } (let ((granted (transfer-grant! (registry manager) id (dict-ref decoded amount: 0)))) (if (eq? (dict-ref granted status: #f) 'refused) granted (let loop ((frames '())) (let* ((current (active-entry manager id)) (credit (dict-ref current credit: 0)) (total (dict-ref current bytes: 0)) (expected (dict-ref current expected-size: 0))) (cond ((= total expected) (let ((done (transfer-end! (registry manager) id (dict-ref current next-seq: 0) total))) (cleanup-entry! current #f) (transfer-remove! (registry manager) id) #{ status: 'complete progress: total frames: (reverse (cons (encode-transfer-end id (dict-ref current next-seq: 0) total) frames)) })) ((= credit 0) #{ status: 'active progress: total frames: (reverse frames) }) (else (let* ((want (let ((remaining (- expected total))) (cond ((< credit transfer-chunk-bytes) (if (< credit remaining) credit remaining)) ((< transfer-chunk-bytes remaining) transfer-chunk-bytes) (else remaining)))) (bytes (read-bytevector want (dict-ref meta port: #f)))) (if (or (eof-object? bytes) (not (= (bytevector-length bytes) want))) (begin (file-transfer-cancel! manager id 'short-read) #{ status: 'refused reason: 'short-read frames: (reverse frames) }) (let* ((seq (dict-ref current next-seq: 0)) (accepted (transfer-batch! (registry manager) id seq bytes))) (if (not (eq? (dict-ref accepted status: #f) 'accepted)) accepted (loop (cons (encode-transfer-batch id seq bytes) frames))))))))))))))) (define (file-transfer-end! manager end-frame) (let* ((decoded (decode-transfer-end end-frame)) (id (and decoded (dict-ref decoded id: #f))) (entry (and id (active-entry manager id))) (meta (entry-meta entry))) (if (not (and decoded entry meta (eq? (dict-ref meta kind: #f) 'upload))) #{ status: 'refused reason: 'unknown-id } (let ((done (transfer-end! (registry manager) id (dict-ref decoded next-seq: #f) (dict-ref decoded total: #f)))) (if (not (eq? (dict-ref done status: #f) 'complete)) done (guard (e (#t (cleanup-entry! entry #f) (transfer-remove! (registry manager) id) #{ status: 'refused reason: 'commit-failed })) (flush-output-port (dict-ref meta port: #f)) (close-port (dict-ref meta port: #f)) ;; Publish directly from the captured metadata. Do not ;; rebuild the entry after close: dict updates may share ;; structure with the live entry in native builds. (rename-file (dict-ref meta temporary: "") (dict-ref meta target: "")) (transfer-remove! (registry manager) id) #{ status: 'complete id: id path: (dict-ref meta target: "") bytes: (dict-ref done bytes: 0) })))))) (define (file-transfer-cancel! manager id reason) (let ((entry (active-entry manager id))) (if (not entry) #{ status: 'refused reason: 'unknown-id } (begin (transfer-remove! (registry manager) id) (cleanup-entry! entry #f) #{ status: 'cancelled id: id reason: reason })))) (define (file-transfer-close-all! manager reason) (let* ((ids (transfer-ids (registry manager))) ;; Capture before cancellation mutates the registry. Some native ;; list walkers reuse their traversal spine. (count (length ids))) (for-each (lambda (id) (file-transfer-cancel! manager id reason)) ids) count)) ))packages/slate-cli/src/slate-cli/sessions.sglmodified
sessions-conn-closed! sessions-agent-oauth-callback! start-reaper! hub-session-count hub-session-count hub-session-summaries hub-quiescent? hub-holds-sid? ;; testable internals make-ring ring-append! ring-bytes ring-length u16-be u32-be u16-at u32-at build-grants build-grants sessions-authorize-open! ;; R6 reaper internals. `reap-pass` and `reap-escalate!` are exported so ;; the exit gate can drive the SAME procedures the reaper loop drives, ;; rather than a reimplementation of them — `reaper-loop` adds only a (define (make-session-hub) ;; Agent sessions live in this same hub object, not in a parallel ;; daemon table. Their typed event plane is implemented by agent/hub. (vector 'session-hub (cell 0) (cell '()) (make-agent-hub))) (vector 'session-hub (cell 0) (cell '()) (make-agent-hub) (cell '()))) ; next-chan, terminal sessions, agent sessions (define (sessions-start-agents! hub config) (hub-set-sessions! hub (remove-eq hs (hub-sessions hub)))) (define (hub-session-count hub) (length (hub-sessions hub))) ;; A condemned session leaves the catalog before its signal ladder ends. ;; An independent owner must finish that work before it may exit. (define (hub-quiescent? hub) (and (= (hub-session-count hub) 0) (null? (cell-get (vector-ref hub 4))))) ;; Find a principal-owned session by id — the principal check is the ;; capability gate: a connection can only ever name its OWN sessions. ;; Control lane: find by DURABLE STRING sid. ;; 12 pump-window (hub's mirror of the sigil-system window: += on ;; session-credit, -= on each delivered data event; used to clamp ;; the window on reattach so it can't inflate unboundedly) ;; 13 chan 14 bounded capture-only latency markers 15 launch metadata (define (make-hs sid chan principal kind sess ring wake ttl) (vector 'hs sid principal kind sess ring #f 0 wake (current-second) #f ttl 0 chan)) (vector 'hs sid principal kind sess ring #f 0 wake (session-clock) #f ttl 0 chan '() #{})) ;; Preserve wall-time retention, including time the phone spends suspended. ;; Linux/Android's CLOCK_MONOTONIC excludes suspend; using current-jiffy for ;; this policy would silently turn 72 hours into 72 hours of awake time. (define (session-clock) (current-second)) (define (hs-sid h) (vector-ref h 1)) (define (hs-chan h) (vector-ref h 13)) (define (hs-principal h) (vector-ref h 2)) (define (hs-ttl h) (vector-ref h 11)) (define (hs-pump-window h) (vector-ref h 12)) (define (set-hs-pump-window! h v) (vector-set! h 12 v)) (define (hs-traces h) (vector-ref h 14)) (define (set-hs-traces! h v) (vector-set! h 14 v)) (define (bounded-u32 n) (max 0 (min 4294967295 n))) (define (trace-register! hs body) (when (= (bytevector-length body) 4) (let* ((entry (vector (u32-at body 0) (current-milliseconds) -1)) (traces (append (hs-traces hs) (list entry)))) (set-hs-traces! hs (if (> (length traces) 32) (cdr traces) traces))))) (define (trace-note-write! hs elapsed) (let loop ((xs (hs-traces hs))) (when (pair? xs) (if (< (vector-ref (car xs) 2) 0) (vector-set! (car xs) 2 (bounded-u32 elapsed)) (loop (cdr xs)))))) (define (trace-take-output! hs) ;; A PTY may coalesce several rapid writes into one output chunk. Retire ;; every marker whose write has completed against that chunk; leaving ;; them queued one-at-a-time drifts later markers onto unrelated output. (let loop ((xs (hs-traces hs)) (taken '())) (if (and (pair? xs) (>= (vector-ref (car xs) 2) 0)) (loop (cdr xs) (cons (car xs) taken)) (begin (set-hs-traces! hs xs) (reverse taken))))) (define (trace-payload entry) (bytevector-append (u32-be (vector-ref entry 0)) (u32-be (bounded-u32 (vector-ref entry 2))) (u32-be (bounded-u32 (- (current-milliseconds) (vector-ref entry 1)))))) ;; Credit the sigil-system pump window by n and mirror it locally. (define (grant-pump! hs n) ((session-verb? verb) ;; The Enclave node's steady-state scheduler owns the stream, ;; writer, and PTY consumer tasks together. (dispatch-req daemon conn env verb)) (let ((backend (dict-ref daemon session-backend: #f))) (if backend (backend daemon conn 'text text) (dispatch-req daemon conn env verb)))) ((agent-verb? verb) (ensure-writer! conn) (let ((hub (dict-ref daemon sessions: #f)) (string-append "web:" (conn-origin conn))) ;; ---- session/open ---------------------------------------------- ;; The private PTY owner receives only operations authorized by the ;; transport node. Use the same grant checks as sigil-system/session-open; ;; check each OPEN, including on a previously established owner link. (define (sessions-authorize-open! daemon conn args) (let* ((config (dict-ref daemon config: #f)) (origin (conn-origin conn)) (argv (to-string-list (dict-ref args argv: #f))) (kind (kind-of (dict-ref args kind: "pty"))) (cwd (dict-ref args cwd: #f)) (g (or (conn-state-get conn "grants" #f) (build-grants (principal-of conn) (grants-for-origin config origin))))) (unless (and kind (pair? argv)) (error "invalid session spec")) (when (eq? kind 'pty) (grant-assert! g 'pty #t)) (grant-assert! g 'exec (car argv)) (when (string? cwd) (grant-assert! g 'fs-read cwd)))) (define (do-open daemon conn id args) (let* ((hub (dict-ref daemon sessions: #f)) (config (dict-ref daemon config: #f)) (principal (string-append "web:" origin)) (argv (to-string-list (dict-ref args argv: #f))) (kind (kind-of (dict-ref args kind: "pty"))) (cwd (dict-ref args cwd: #f)) (cols (dict-ref args cols: 80)) (rows (dict-ref args rows: 24))) (cond ((and (dict-ref daemon max-sessions: #f) (>= (+ (hub-session-count hub) (length (cell-get (vector-ref hub 4)))) (dict-ref daemon max-sessions: #f))) (reply-err conn id "busy" "this terminal owner has reached its session limit")) ((not (pair? argv)) (log-warn* "node/session-refused" mod: "node" req-id: id verb: "session/open" code: "bad-args" (kind-label (dict-ref args kind: "pty")) " -- only \"pty\" and \"process\" are supported"))) (else (let ((g (build-grants principal (grants-for-origin config origin))) ;; Direct peers authenticate outside the browser-origin pairing ;; path and cache their verified grant table on the connection. ;; Rebuilding solely from daemon config here discards that posture ;; (resources already use the cache) and denies every direct PTY. (let ((g (or (conn-state-get conn "grants" #f) (build-grants principal (grants-for-origin config origin)))) (t-open (op-clock))) ;; THE RAISE REASON IS NOW RECORDED BEFORE IT IS DISCARDED. ;; This guard collapses every cause of a failed terminal open — reason: (safe-raise-message e) duration-ms: (op-elapsed-ms t-open)) (reply-err conn id "open-failed" "session-open failed"))) (let* ((spec (dict kind: kind argv: argv cols: cols rows: rows credit: 0)) (let* ((spec0 (dict kind: kind argv: argv cols: cols rows: rows credit: 0)) ;; cwd is optional at the session seam. Passing #f as a ;; present cwd reaches the filesystem grant validator as ;; a path and rejects every legacy caller that omitted it. (spec (if (string? cwd) (dict-set spec0 cwd: cwd) spec0)) (sess (session-open g spec)) (sid (mint-sid!)) (chan (hub-next-chan! hub)) (wake (make-channel 1)) (hs (make-hs sid chan principal kind sess ring wake (kind-ttl config kind)))) (vector-set! hs 15 #{ command: (safe-argv0 argv) cwd: (if (string? cwd) cwd "") created: (exact (floor (current-second))) started: (session-clock) }) (ensure-writer! conn) (set-hs-attached! hs conn) (hub-add! hub hs) (if (string? m) m "<non-string>")))) ;; ---- session/list ---------------------------------------------- (define (hub-session-summaries hub principal) (map-list session-summary (filter-list (lambda (hs) (string=? (hs-principal hs) principal)) (hub-sessions hub)))) (define (do-list daemon conn id) (let* ((hub (dict-ref daemon sessions: #f)) (principal (principal-of conn)) (mine (filter-list (lambda (hs) (string=? (hs-principal hs) principal)) (hub-sessions hub)))) (mine (hub-session-summaries hub principal)) (cell (dict-ref daemon session-directory: #f)) (discover (and cell (vector-ref cell 0)))) (reply-ok conn id #{ sessions: (list->array (map-list session-summary mine)) }))) (dict-set (if discover (discover principal mine) #{ sessions: (list->array mine) unavailable: 0 }) catalog-version: 1)))) ;; R6: `idle` and `ttl` are ADDITIVE fields on an existing reply shape, so ;; no protocol version moves — an older client ignores them, and a newer ;; n => reaped after n idle seconds. Found in adversarial review — the ;; sentinel disease surviving one layer out from where it was purged. (define (session-summary hs) #{ sid: (hs-sid hs) chan: (hs-chan hs) kind: (symbol->string (hs-kind hs)) alive: (not (hs-dead? hs)) attached: (if (hs-attached hs) #t #f) ;; WHOLE SECONDS. `current-second` is a FLOAT here (probed: it reads ;; 1.78768e+09, not an integer), so the raw difference would put six ;; decimal places of meaningless precision on the wire for a value the ;; UI renders as "3h". Floored rather than rounded, so a freshly ;; detached session reads 0 and never 1. idle: (exact (floor (max 0 (- (current-second) (hs-last hs))))) ttl: (or (hs-ttl hs) 0) }) (let ((meta (if (> (vector-length hs) 15) (vector-ref hs 15) #{}))) #{ command: (dict-ref meta command: "shell") cwd: (dict-ref meta cwd: "") created: (dict-ref meta created: 0) age: (exact (floor (max 0 (- (session-clock) (dict-ref meta started: (session-clock)))))) sid: (hs-sid hs) chan: (hs-chan hs) kind: (symbol->string (hs-kind hs)) alive: (not (hs-dead? hs)) attached: (if (hs-attached hs) #t #f) ;; Report elapsed whole seconds, so a freshly detached session ;; reads zero even when the clock has subsecond precision. idle: (exact (floor (max 0 (- (session-clock) (hs-last hs))))) ttl: (or (hs-ttl hs) 0) })) ;; ---- session/attach -------------------------------------------- (define (do-attach daemon conn id args) (else (ensure-writer! conn) (set-hs-attached! hs conn) ;; Ack first, then replay scrollback, then resume the pump. (reply-ok conn id #{ pv: protocol-version sid: sid chan: (hs-chan hs) kind: (symbol->string (hs-kind hs)) }) ;; The scrollback is read on its own line and BOUND before it is ;; logged. `ring-bytes` copies out of the replay ring — real work, ;; and inlining it into a keyword slot would mean the client got ;; its scrollback only when debug logging happened to be on. (let ((scroll (ring-bytes (hs-ring hs)))) ;; acknowledged and logged. `ring-bytes` copies out of the replay ;; ring — real work, and evaluating it twice could make the declared ;; byte count disagree with the frame if the ring advances between ;; the two reads. (let ((scroll (ring-bytes (hs-ring hs))) (summary (session-summary hs))) ;; Ack first, then replay scrollback, then resume the pump. The ;; additive count lets a reconnecting emulator distinguish this ;; authoritative reseed from ordinary live output. In particular, ;; zero means there is no replay frame and the retained screen must ;; not be cleared. (reply-ok conn id #{ pv: protocol-version sid: sid created: (dict-ref summary created: 0) age: (dict-ref summary age: 0) idle: (dict-ref summary idle: 0) ttl: (or (hs-ttl hs) 0) chan: (hs-chan hs) kind: (symbol->string (hs-kind hs)) replay-bytes: (bytevector-length scroll) }) (when (> (bytevector-length scroll) 0) (enqueue-binary! conn frame-data-out (hs-chan hs) scroll)) (log-debug* "node/terminal-attach" mod: "node" req-id: id ;; last OUTPUT. R1 is the phase that makes "detached" the normal ;; parked state, so the bug stops being theoretical here. Named as an ;; R6 prerequisite in the phase plan; it has to land with R1. (set-hs-last! hs (current-second)) (set-hs-last! hs (session-clock)) (channel-try-send (hs-wake hs) #t)) ;; Re-arm the flow window on open/attach: reset wire-credit to the ;; connection. Always mirror into the ring first (scrollback), so a ;; killed-then-reopened tab replays recent output. (define (deliver-out! hub hs bytes) (set-hs-last! hs (current-second)) (set-hs-last! hs (session-clock)) (ring-append! (hs-ring hs) bytes) (let ((n (bytevector-length bytes))) ;; The pump consumed n bytes of its window to produce this event; ;; consumer, so forwarding to a socket buffer is not ;; "consumed" yet. Crediting on both paths would double the ;; window and defeat the kernel-backpressure design (§2.4). (set-hs-credit! hs (- (hs-credit hs) n)) (set-hs-credit! hs (- (hs-credit hs) n)) (for-each (lambda (trace) (enqueue-binary! conn frame-trace-out (hs-chan hs) (trace-payload trace))) (trace-take-output! hs)) ;; PER-CHUNK OUTPUT IS TRACE, NOT DEBUG. This runs for every ;; chunk the pty produces — `cat largefile` is thousands of ;; these. At debug it would bury every other line in the file ;;; `payload` is the WS binary frame body — the raw stream-frame ;;; bytes `[u8 type][u32 id][payload]`. (define (sessions-handle-binary daemon conn payload) (let ((backend (dict-ref daemon session-backend: #f))) (if backend (backend daemon conn 'binary payload) (sessions-handle-local-binary daemon conn payload)))) (define (sessions-handle-local-binary daemon conn payload) (let ((fr (decode-stream-frame payload))) (if (not (stream-frame? fr)) ;; Was silently discarded. A malformed binary frame is either a ;; on the node, which is where you look when the client says ;; its terminal stopped taking input. ((not hs) (log-warn* "node/frame-refused" mod: "node" reason: "no-such-session" chan: chan frame-type: type origin: (conn-origin conn)) (enqueue-binary! conn frame-error chan (ascii-bytes "no such session"))) ;; CREDIT ACKNOWLEDGES BYTES THE NODE ALREADY SENT. A short ;; process can exit and leave the hub before those acks make ;; the round trip, especially on Android where the holder and ;; relay pumps are scheduled more coarsely. Turning that ;; ordinary late ack into frame-error lets the error overtake ;; the already-queued output/exit and makes a successful PTY ;; appear to have failed with "no such session". ;; ;; Credit is additive flow-control bookkeeping, so applying it ;; after retirement has no remaining effect and ignoring it is ;; idempotent. Every operation that can change user state ;; (input, resize, signal, close) keeps the loud refusal below. (if (= type frame-credit) (log-debug* "node/frame-ignored" mod: "node" reason: "late-credit-after-session-retired" chan: chan frame-type: type origin: (conn-origin conn)) (begin (log-warn* "node/frame-refused" mod: "node" reason: "no-such-session" chan: chan frame-type: type origin: (conn-origin conn)) (enqueue-binary! conn frame-error chan (ascii-bytes "no such session"))))) ;; Input frames may only come from the currently-attached ;; connection (a detached session takes no keystrokes). ((not (eq? (hs-attached hs) conn)) (guard (e (#t -1)) (if (bytevector? p) (bytevector-length p) -1))) (define (drive-frame hs type body) (set-hs-last! hs (current-second)) (set-hs-last! hs (session-clock)) (cond ;; INPUT. Trace, for the same volume reason as `node/terminal-out`: ;; this is one line per keystroke. ;; produced no evidence of any kind. The behaviour is unchanged — the ;; write still fails silently as far as the client is concerned — ;; but the node now records that it happened. ((= type frame-trace-in) (trace-register! hs body)) ((= type frame-data-in) (log-trace* "node/terminal-in" mod: "node" sid: (hs-sid hs) chan: (hs-chan hs) bytes: (safe-bv-length body)) (guard (e (#t (log-warn* "node/terminal-write-failed" mod: "node" (let ((started (current-milliseconds))) (guard (e (#t (log-warn* "node/terminal-write-failed" mod: "node" sid: (hs-sid hs) chan: (hs-chan hs) bytes: (safe-bv-length body) reason: (safe-raise-message e)) #f)) (session-write (hs-sess hs) body))) (session-write (hs-sess hs) body) (trace-note-write! hs (- (current-milliseconds) started))))) ((= type frame-resize) (apply-resize hs body)) ((= type frame-signal) (apply-signal hs body)) ((= type frame-credit) (apply-credit hs body)) ;; reattach that the client may never send. (define (sessions-conn-closed! daemon conn) (agent-service-detach! conn) (let ((backend (dict-ref daemon session-backend: #f))) (when backend (backend daemon conn 'close #f))) (let ((hub (dict-ref daemon sessions: #f)) (t0 (op-clock))) (when hub (loop)))) (define (reap-pass hub) (let ((now (current-second))) (let ((now (session-clock))) (for-each (lambda (hs) (cond (define (reap-idle! hub hs now) (let ((idle (- now (hs-last hs))) (sess (hs-sess hs))) (cell-set! (vector-ref hub 4) (cons hs (cell-get (vector-ref hub 4)))) (set-hs-dead! hs #t) (hub-remove! hub hs) (channel-try-send (hs-wake hs) #t) ;; skipped — this call is the `process-wait`, not a second ;; execution. (session-close sess) (unless (session-alive? sess) (cell-set! (vector-ref hub 4) (remove-eq hs (cell-get (vector-ref hub 4))))) (if (eq? ended 'survived) (log-error* "node/reap-survived" mod: "node" sid: (hs-sid hs) ;;; That is load-bearing. Had a zombie read as alive, every ladder would ;;; have run to SIGKILL and the stand-down arm could never pass. (define (wait-for-exit sess seconds) (let ((deadline (+ (current-second) seconds))) (let ((deadline (+ (current-jiffy) (* seconds (jiffies-per-second))))) (let poll () (cond ((not (session-alive? sess)) #t) ((>= (current-second) deadline) #f) ((>= (current-jiffy) deadline) #f) (else (sleep 0.05) (poll)))))) ;;; The idle TTL for a session of `kind`, in seconds, or `#f` for never.packages/slate-cli/src/slate-cli/terminal/discovery.sgladded
;;; (slate-cli terminal discovery) — bounded queries of node-local session hubs.(define-library (slate-cli terminal discovery) (import (sigil array) (sigil async) (sigil fs) (sigil io) (sigil json) (sigil path) (sigil socket) (sigil string) (sigil time)) (export terminal-discover terminal-query) (begin ;; The directory is private to the node user. Only summaries cross this ;; socket; the authenticated connection supplies the principal, never args. (define (terminal-query path principal) (let ((sock (guard (e (#t #f)) (unix-connect path)))) (and sock (let ((result (guard (e (#t #f)) (socket-write sock (string-append "LIST " (json-encode principal) "\n")) (socket-set-non-blocking! sock #t) (let ((deadline (+ (current-second) 0.25))) (let loop ((buf (bytevector))) (cond ((and (> (bytevector-length buf) 0) (= (bytevector-u8-ref buf (- (bytevector-length buf) 1)) 10)) (let ((reply (json-decode (utf8->string buf)))) (and (dict? reply) (array? (dict-ref reply sessions: #f)) (array->list (dict-ref reply sessions: #f))))) ((or (> (bytevector-length buf) 65536) (> (current-second) deadline)) #f) (else (let ((bytes (socket-read-bytevector sock 4096))) (cond ((eof-object? bytes) #f) ((and (bytevector? bytes) (> (bytevector-length bytes) 0)) (loop (bytevector-append buf bytes))) (else (sleep 0.005) (loop buf))))))))))) (socket-close sock) result)))) (define (terminal-discover dir own-path principal local) (let ((paths (guard (e (#t '())) (or (directory-list dir) '())))) (let loop ((names paths) (rows local) (unavailable 0) (count 0)) (if (or (null? names) (>= count 32)) #{ sessions: (list->array rows) unavailable: (+ unavailable (if (null? names) 0 1)) } (let* ((name (car names)) (path (path-join dir name))) (if (or (equal? path own-path) (not (or (equal? name "node.sock") (and (string-starts-with? name "h-") (string-ends-with? name ".sock"))))) (loop (cdr names) rows unavailable count) (let ((found (terminal-query path principal))) (loop (cdr names) (if found (append rows (map (lambda (row) (dict-set row route: "relay")) found)) rows) (+ unavailable (if found 0 1)) (+ count 1))))))))) ))packages/slate-cli/src/slate-cli/terminal/ipc.sgladded
;;; Private node IPC uses the existing bounded WebSocket frame codec.;;; There is no HTTP upgrade and no externally reachable listener here.(define-library (slate-cli terminal ipc) (import (sigil async) (sigil socket) (sigil time) (sigil math) (sigil websocket frame) (slate-cli listen)) (export terminal-ipc terminal-ipc-close! terminal-ipc-read! terminal-ipc-send! terminal-ipc-json!) (begin (define (terminal-ipc sock) (socket-set-non-blocking! sock #t) (vector sock (bytevector) #f (make-virtual-conn (lambda (bytes) (write-all! sock bytes)) "ipc"))) (define (terminal-ipc-close! ipc) (unless (vector-ref ipc 2) (vector-set! ipc 2 #t) (socket-close (vector-ref ipc 0)))) (define (terminal-ipc-send! ipc bytes) (and (not (vector-ref ipc 2)) (conn-send! (vector-ref ipc 3) bytes))) (define (terminal-ipc-json! ipc value) (ws-send-control! (vector-ref ipc 3) value)) ;; A deadline is required for handshakes/queries; steady-state readers ;; park on readability, so a terminal owner has no periodic input poll. (define (terminal-ipc-read! ipc . timeout) (let ((deadline (and (pair? timeout) (+ (current-second) (car timeout))))) (let loop () (let* ((buf (vector-ref ipc 1)) (decoded (decode-frame buf)) (frame (frame-decode-result-frame decoded))) (cond ((vector-ref ipc 2) #f) ((> (bytevector-length buf) (* 1024 1024)) (terminal-ipc-close! ipc) #f) (frame (vector-set! ipc 1 (bytevector-copy buf (frame-decode-result-bytes-consumed decoded))) frame) ((and deadline (>= (current-second) deadline)) #f) (else (let ((bytes (socket-read-bytevector (vector-ref ipc 0) 65536))) (cond ((eof-object? bytes) (terminal-ipc-close! ipc) #f) ((and (bytevector? bytes) (> (bytevector-length bytes) 0)) (vector-set! ipc 1 (bytevector-append buf bytes)) (loop)) (else (await-readable-any (list (vector-ref ipc 0)) timeout-ms: (if deadline (max 1 (exact (floor (* 1000 (- deadline (current-second)))))) 1000)) (loop)))))))))) ))packages/slate-cli/src/slate-cli/terminal/owner.sgladded
;;; Restart-independent PTY ownership. Network transports delegate their;;; authenticated session plane over a private local socket. Resource RPCs,;;; watches, pairing and agent sessions remain in the network process.(define-library (slate-cli terminal owner) (import (sigil array) (sigil async) (sigil fs) (sigil io) (sigil json) (sigil path) (sigil process) (sigil socket) (sigil string) (sigil time) (sigil log) (sigil channels) (sigil websocket frame) (sigil system grant) (slate-cli config) (slate-cli listen) (slate-cli sessions) (slate-cli wire) (slate-cli node pairing) (slate-cli terminal ipc)) (export make-terminal-backend terminal-owner-holds? run-terminal-owner!) (begin (define owner-version 1) (define owner-limit 8) (define session-limit 64) (define connection-limit 128) (define empty-grace 10) (define (owner-dir config) ;; Keep sockaddr_un paths short enough for Termux's long home path. (path-join (config-state-dir config) "pty")) (define (owner-identity config) (let ((identity (pairing-load-identity (config-state-dir config)))) (if identity (dict-ref identity key-id: "") "local"))) (define (owner-paths config) (let ((dir (owner-dir config))) (map (lambda (name) (path-join dir name)) (filter (lambda (name) (and (string-starts-with? name "o-") (string-ends-with? name ".sock"))) (or (directory-list dir) '()))))) (define (frame-json frame) (and frame (= (ws-frame-opcode frame) opcode-text) (guard (e (#t #f)) (json-decode (utf8->string (ws-frame-payload frame)))))) (define (owner-connect config path origin mode) (let* ((identity (owner-identity config)) (sock (guard (e (#t #f)) (unix-connect path)))) (and sock (let ((ipc (terminal-ipc sock))) (terminal-ipc-json! ipc #{ version: owner-version identity: identity origin: origin mode: mode }) (let ((hello (frame-json (terminal-ipc-read! ipc 0.5)))) (if (and (dict? hello) (equal? (dict-ref hello version: 0) owner-version) (equal? (dict-ref hello identity: #f) identity) (equal? (dict-ref hello endpoint: #f) path)) ipc (begin (terminal-ipc-close! ipc) #f))))))) (define (owner-query config path principal sid) (let ((ipc (owner-connect config path "" "query"))) (and ipc (let ((reply (guard (e (#t #f)) (terminal-ipc-json! ipc #{ principal: principal sid: sid }) (frame-json (terminal-ipc-read! ipc 0.5))))) (terminal-ipc-close! ipc) (and (dict? reply) reply))))) (define (terminal-owner-holds? config sid) (let loop ((paths (owner-paths config)) (left owner-limit)) (and (pair? paths) (> left 0) (let ((reply (owner-query config (car paths) "" sid))) (or (and reply (dict-ref reply holds: #f)) (loop (cdr paths) (- left 1))))))) ;; Never bind over a shared pathname: unix-listen unlinks before binding. ;; Each owner has an unguessable endpoint, discovered by protocol+identity, ;; so a crash/restart or PID reuse cannot redirect a surviving terminal. ;; Redundant empty owners from simultaneous first opens retire themselves. (define (spawn-owner! config) (let* ((dir (owner-dir config)) (path (path-join dir (string-append "o-" (substring (mint-sid!) 2 18) ".sock")))) (ensure-directory dir) (unless (= 0 (process-run "chmod" "700" dir)) (error "cannot secure terminal owner directory")) (when (>= (length (owner-paths config)) owner-limit) (error "terminal owner limit reached")) (let ((proc (process-spawn (car (command-line)) "node" "_terminal-owner" path (config-state-dir config) (number->string (config-session-ttl-arg config)) new-process-group: #t die-with-parent: #f in: "/dev/null" out: "/dev/null" err: (path-join dir "owner.log") append: #t))) (unless proc (error "cannot start terminal owner")) ;; Reap our child if it exits while this main node is still alive. ;; After a main-node crash the system's process reaper adopts it. (go (let loop () (if (process-alive? proc) (begin (sleep 1) (loop)) (process-wait proc)))) (let ((deadline (+ (current-second) 5))) (let wait () (let ((reply (and (file-exists? path) (owner-query config path "" #f)))) (cond (reply path) ((or (not (process-alive? proc)) (> (current-second) deadline)) (error "terminal owner startup failed")) (else (sleep 0.02) (wait))))))))) (define (inventory config principal) (let loop ((paths (owner-paths config)) (left owner-limit) (rows '()) (owners '()) (unavailable 0)) (if (or (null? paths) (= left 0)) (vector rows owners (+ unavailable (if (null? paths) 0 1))) (let* ((path (car paths)) (reply (owner-query config path principal #f))) (if reply (loop (cdr paths) (- left 1) (append rows (array->list (dict-ref reply sessions: #[]))) (cons path owners) unavailable) (begin ;; Remove only endpoints whose OS socket cannot connect. ;; An incompatible/live owner remains visible as unavailable. (let ((sock (guard (e (#t #f)) (unix-connect path)))) (if sock (socket-close sock) (guard (e (#t #f)) (delete-file path)))) (loop (cdr paths) (- left 1) rows owners (+ unavailable 1)))))))) (define (owner-for-sid config sid) (let loop ((paths (owner-paths config)) (left owner-limit)) (and (pair? paths) (> left 0) (let ((reply (owner-query config (car paths) "" sid))) (if (and reply (dict-ref reply holds: #f)) (car paths) (loop (cdr paths) (- left 1))))))) ;; Map channels at the transport boundary: a connection may attach to ;; sessions held by different surviving owners whose u32 counters overlap. ;; SID remains unchanged; the frontend channel counter is connection-local. (define (bridge-channel! conn bridge remote) (let ((found (assv remote (vector-ref bridge 2)))) (if found (cdr found) (let ((next (+ 1 (conn-state-get conn "owner-next-channel" 0)))) (conn-state-set! conn "owner-next-channel" next) (vector-set! bridge 2 (cons (cons remote next) (vector-ref bridge 2))) next)))) (define (forward-owner-frame! conn bridge frame) (let ((payload (ws-frame-payload frame))) (cond ((= (ws-frame-opcode frame) opcode-text) (let* ((reply (frame-json frame)) (result (and reply (dict-ref reply result: #f))) (chan (and (dict? result) (dict-ref result chan: #f)))) (when reply (vector-set! bridge 4 (filter (lambda (id) (not (equal? id (dict-ref reply id: #f)))) (vector-ref bridge 4))) (ws-send-control! conn (if chan (dict-set reply result: (dict-set result chan: (bridge-channel! conn bridge chan))) reply))))) ((= (ws-frame-opcode frame) opcode-binary) (let ((fr (decode-stream-frame payload))) (when fr (ws-send-binary! conn (encode-stream-frame (stream-frame-type fr) (bridge-channel! conn bridge (stream-frame-id fr)) (stream-frame-payload fr))) (when (= (stream-frame-type fr) frame-close) (vector-set! bridge 2 (filter (lambda (entry) (not (= (car entry) (stream-frame-id fr)))) (vector-ref bridge 2)))))))))) (define (open-bridge! config conn path) (let* ((bridges (conn-state-get conn "terminal-owner-links" '())) (found (assoc path bridges))) (if (and found (not (vector-ref (cdr found) 3))) (cdr found) (let ((ipc (owner-connect config path (conn-origin conn) "stream"))) (unless ipc (error "terminal owner unavailable")) (let ((bridge (vector path ipc '() #f '()))) (conn-state-set! conn "terminal-owner-links" (cons (cons path bridge) bridges)) (go (guard (e (#t #f)) (let loop () (let ((frame (terminal-ipc-read! ipc))) (when frame (forward-owner-frame! conn bridge frame) (loop))))) (vector-set! bridge 3 #t) (terminal-ipc-close! ipc) (for-each (lambda (id) (ws-send-control! conn #{ t: "err" id: id code: "unavailable" message: "the terminal owner disconnected" })) (vector-ref bridge 4)) (for-each (lambda (entry) (ws-send-binary! conn (encode-stream-frame frame-error (cdr entry) (string->utf8 "terminal owner disconnected")))) (vector-ref bridge 2)) (conn-state-set! conn "terminal-owner-links" (filter (lambda (entry) (not (eq? (cdr entry) bridge))) (conn-state-get conn "terminal-owner-links" '())))) bridge))))) (define (proxy-binary! conn payload) (let ((fr (decode-stream-frame payload))) (when fr (let loop ((links (conn-state-get conn "terminal-owner-links" '()))) (when (pair? links) (let* ((bridge (cdar links)) (mapping (find (lambda (entry) (= (cdr entry) (stream-frame-id fr))) (vector-ref bridge 2)))) (if mapping (terminal-ipc-send! (vector-ref bridge 1) (encode-server-binary-frame (encode-stream-frame (stream-frame-type fr) (car mapping) (stream-frame-payload fr)))) (loop (cdr links))))))))) (define (make-terminal-backend config) ;; Serialize first opens in this process; transport holders also discover ;; these endpoints, and never own PTYs themselves. (let ((lock (make-channel 1))) (channel-send lock #t) (lambda (daemon conn action payload) (case action ((close) (for-each (lambda (entry) (terminal-ipc-close! (vector-ref (cdr entry) 1))) (conn-state-get conn "terminal-owner-links" '())) (conn-state-set! conn "terminal-owner-links" '())) ((binary) (proxy-binary! conn payload)) ((text) (let* ((env (decode-control payload)) (id (dict-ref env id: 0)) (verb (dict-ref env verb: "")) (args (dict-ref env args: #{}))) (guard (e (#t (log-warn* "terminal owner request failed" mod: "terminal" verb: verb reason: e) (ws-send-control! conn #{ t: "err" id: id code: "unavailable" message: "the terminal owner could not handle this request" }))) (cond ((equal? verb "session/list") (let ((catalog (inventory config (string-append "web:" (conn-origin conn))))) (ws-send-control! conn #{ t: "ok" id: id result: #{ catalog-version: 1 sessions: (list->array (vector-ref catalog 0)) unavailable: (vector-ref catalog 2) } }))) (else (when (equal? verb "session/open") (sessions-authorize-open! daemon conn args)) (let* ((sid (dict-ref args sid: #f)) (path (if (equal? verb "session/open") (begin (channel-receive lock) (dynamic-wind (lambda () #f) (lambda () (let ((catalog (inventory config ""))) (if (pair? (vector-ref catalog 1)) (car (vector-ref catalog 1)) (spawn-owner! config)))) (lambda () (channel-send lock #t)))) (and (valid-sid? sid) (owner-for-sid config sid))))) (if path (let ((bridge (open-bridge! config conn path))) (vector-set! bridge 4 (cons id (vector-ref bridge 4))) (unless (terminal-ipc-send! (vector-ref bridge 1) (encode-server-text-frame payload)) (error "terminal owner disconnected"))) (ws-send-control! conn #{ t: "err" id: id code: "no-session" message: "unknown session" })))))))))))) (define (serve-owner-client! config path identity hub ipc) (let ((conn #f)) (guard (e (#t (log-warn* "terminal owner connection failed" mod: "terminal" reason: e))) (let ((hello (frame-json (terminal-ipc-read! ipc 1)))) (when (and (dict? hello) (equal? (dict-ref hello version: 0) owner-version) (equal? (dict-ref hello identity: #f) identity)) (terminal-ipc-json! ipc #{ version: owner-version identity: identity endpoint: path pid: (process-id) }) (if (equal? (dict-ref hello mode: "") "query") (let ((query (frame-json (terminal-ipc-read! ipc 1)))) (when (dict? query) (terminal-ipc-json! ipc #{ sessions: (list->array (hub-session-summaries hub (dict-ref query principal: ""))) holds: (hub-holds-sid? hub (dict-ref query sid: #f)) }))) (when (and (equal? (dict-ref hello mode: "") "stream") (string? (dict-ref hello origin: #f))) (set! conn (make-virtual-conn (lambda (bytes) (terminal-ipc-send! ipc bytes)) (dict-ref hello origin: ""))) ;; The same OS user can already spawn these commands. ;; Network grant enforcement stays at every frontend OPEN; ;; this private endpoint executes that delegated operation. (conn-state-set! conn "grants" (grant-allow-all principal: (string-append "web:" (conn-origin conn)))) (let ((daemon #{ config: config sessions: hub max-sessions: session-limit })) (let loop () (let ((frame (terminal-ipc-read! ipc))) (when frame (cond ((= (ws-frame-opcode frame) opcode-text) (sessions-on-text daemon conn (utf8->string (ws-frame-payload frame)))) ((= (ws-frame-opcode frame) opcode-binary) (sessions-handle-binary daemon conn (ws-frame-payload frame)))) (loop)))))))))) (when conn (sessions-conn-closed! #{ sessions: hub } conn)) (terminal-ipc-close! ipc))) (define (run-terminal-owner! path state-dir ttl) (let* ((config (make-config state-dir: state-dir session-ttl: ttl)) (identity (owner-identity config)) (hub (make-session-hub)) (listener (unix-listen path)) (connections 0) (idle-since (current-jiffy)) (running #t)) (socket-set-non-blocking! listener #t) (with-async (start-reaper! hub config) (go (let accept () (when running (await-readable listener) (let ((sock (tcp-accept listener))) (when sock (if (>= connections connection-limit) (socket-close sock) (begin (set! connections (+ connections 1)) (go (serve-owner-client! config path identity hub (terminal-ipc sock)) (set! connections (- connections 1))))))) (accept)))) (let loop () (sleep 0.1) (when (not (hub-quiescent? hub)) (set! idle-since (current-jiffy))) (if (> (- (current-jiffy) idle-since) (* empty-grace (jiffies-per-second))) (begin (set! running #f) (socket-close listener) (delete-file path) (exit 0)) (loop)))))) ))packages/slate-cli/src/slate-cli/wire.sglmodified
;; Binary frame type codes (design §3.2 / §3.2b) frame-data-in frame-data-out frame-data-err frame-resize frame-signal frame-credit frame-close frame-error frame-trace-in frame-trace-out frame-bulk frame-batch frame-end frame-cancel frame-transfer-credit encode-transfer-batch decode-transfer-batch encode-transfer-end decode-transfer-end encode-transfer-credit decode-transfer-credit ;; Binary frame codec encode-stream-frame decode-stream-frame (define frame-credit #x07) ; payload = u32 bytes granted (define frame-close #x08) ; id = sid (define frame-error #x09) ; payload = JSON detail ;; Capture-only terminal latency markers. They never contain terminal data: ;; trace-in carries a u32 correlation id; trace-out carries that id plus ;; remote receive->PTY-write and receive->first-output durations. (define frame-trace-in #x0a) (define frame-trace-out #x0b) (define frame-bulk #x10) ; id = req-id, payload = sigil-wire bytes (define frame-batch #x20) ; id = req-id, one batch of records (define frame-end #x21) ; id = req-id, transfer complete (get-u32-be bv 1) (bytevector-copy bv header-size len))))) ;; Transfer-plane payloads keep their ordering and accounting fields in a ;; fixed binary prefix. V1 intentionally uses u32 byte totals: it covers ;; the multi-frame gap above 4 MiB while retaining exact arithmetic in the ;; browser and putting an explicit 4 GiB ceiling on one operation. (define (u32? value) (and (integer? value) (>= value 0) (<= value 4294967295))) (define (encode-transfer-batch id seq bytes) (unless (and (u32? id) (u32? seq) (bytevector? bytes)) (error "encode-transfer-batch: invalid id, sequence, or payload")) (let* ((n (bytevector-length bytes)) (payload (make-bytevector (+ 4 n) 0))) (put-u32-be! payload 0 seq) (when (> n 0) (bytevector-copy! payload 4 bytes 0 n)) (encode-stream-frame frame-batch id payload))) (define (decode-transfer-batch frame) (and (stream-frame? frame) (= (stream-frame-type frame) frame-batch) (let ((payload (stream-frame-payload frame))) (and (>= (bytevector-length payload) 4) #{ id: (stream-frame-id frame) seq: (get-u32-be payload 0) bytes: (bytevector-copy payload 4 (bytevector-length payload)) })))) (define (encode-transfer-end id next-seq total) (unless (and (u32? id) (u32? next-seq) (u32? total)) (error "encode-transfer-end: invalid id, sequence, or total")) (let ((payload (make-bytevector 8 0))) (put-u32-be! payload 0 next-seq) (put-u32-be! payload 4 total) (encode-stream-frame frame-end id payload))) (define (decode-transfer-end frame) (and (stream-frame? frame) (= (stream-frame-type frame) frame-end) (= (bytevector-length (stream-frame-payload frame)) 8) #{ id: (stream-frame-id frame) next-seq: (get-u32-be (stream-frame-payload frame) 0) total: (get-u32-be (stream-frame-payload frame) 4) })) (define (encode-transfer-credit id amount) (unless (and (u32? id) (u32? amount) (> amount 0)) (error "encode-transfer-credit: invalid id or amount")) (let ((payload (make-bytevector 4 0))) (put-u32-be! payload 0 amount) (encode-stream-frame frame-transfer-credit id payload))) (define (decode-transfer-credit frame) (and (stream-frame? frame) (= (stream-frame-type frame) frame-transfer-credit) (= (bytevector-length (stream-frame-payload frame)) 4) (let ((amount (get-u32-be (stream-frame-payload frame) 0))) (and (> amount 0) #{ id: (stream-frame-id frame) amount: amount })))) ;; ---------------------------------------------------------------- ;; Control envelope (text frame) codec ;;packages/slate-cli/test/helpers/relay-peer.cjsadded
// A local, byte-transparent stand-in for Enclave's ticketed relay.const http = require('node:http');const crypto = require('node:crypto');const assert = require('node:assert/strict');const {Client} = require('./terminal-client.cjs');async function relayServer() { const server = http.createServer(); const waiting = [], peers = []; server.on('upgrade', (request, socket, head) => { const accept = crypto.createHash('sha1').update(request.headers['sec-websocket-key'] + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64'); socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`); const peer = Object.create(Client.prototype); Object.assign(peer, {socket, queue: [], frames: [], tail: '', sequence: 0, outputBytes: 0}); peer.send = (opcode, payload) => { payload = Buffer.from(payload); if (opcode === 1) { payload = Buffer.concat([Buffer.from([1, 0, 0, 0, 0]), payload]); opcode = 2; } const header = Buffer.alloc(payload.length < 126 ? 2 : 4); header[0] = 128 | opcode; header[1] = payload.length < 126 ? payload.length : 126; if (payload.length >= 126) header.writeUInt16BE(payload.length, 2); socket.write(Buffer.concat([header, payload])); }; let buffer = Buffer.from(head); socket.on('error', error => { peer.error = error; }); socket.on('data', data => { buffer = Buffer.concat([buffer, data]); while (buffer.length >= 2) { const opcode = buffer[0] & 15, masked = buffer[1] & 128; let size = buffer[1] & 127, offset = 2; if (size === 126) { if (buffer.length < 4) return; size = buffer.readUInt16BE(2); offset = 4; } if (size === 127) { if (buffer.length < 10) return; size = Number(buffer.readBigUInt64BE(2)); offset = 10; } assert.ok(size <= 1024 * 1024, 'relay frame stays bounded'); const header = offset + (masked ? 4 : 0); if (buffer.length < header + size) return; const payload = Buffer.from(buffer.subarray(header, header + size)); if (masked) for (let i = 0; i < size; i++) payload[i] ^= buffer[offset + i % 4]; buffer = buffer.subarray(header + size); if (opcode === 1) peer.ticket = payload.toString(); if (opcode === 2 && payload[0] === 1) peer.queue.push(JSON.parse(payload.subarray(5).toString())); if (opcode === 2 && payload[0] === 3) peer.tail = (peer.tail + payload.subarray(5).toString()).slice(-4096); if (opcode === 2) peer.frames.push(payload); if (opcode === 9) peer.send(10, payload); } }); peers.push(peer); if (waiting.length) waiting.shift()(peer); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); return { url: `ws://127.0.0.1:${server.address().port}/stream`, next: () => new Promise(resolve => waiting.push(resolve)), close: async () => { for (const peer of peers) peer.socket.destroy(); await new Promise(resolve => server.close(resolve)); } };}module.exports = {relayServer};packages/slate-cli/test/helpers/terminal-client.cjsadded
const net = require('node:net');const crypto = require('node:crypto');const assert = require('node:assert/strict');const delay = ms => new Promise(resolve => setTimeout(resolve, ms));class Client { constructor(port) { this.buffer = Buffer.alloc(0); this.upgraded = false; this.queue = []; this.outputBytes = 0; this.tail = ''; this.sequence = 0; this.frames = []; this.outputWaiters = []; this.socket = net.connect(port, '127.0.0.1'); this.socket.on('error', error => { this.error = error; }); this.socket.on('connect', () => this.socket.write( `GET /node/ws HTTP/1.1\r\nHost: 127.0.0.1:${process.env.SLATE_PROBE_ORIGIN_PORT || port}\r\nOrigin: http://127.0.0.1:${process.env.SLATE_PROBE_ORIGIN_PORT || port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: ${crypto.randomBytes(16).toString('base64')}\r\n\r\n`)); this.socket.on('data', bytes => { this.buffer = Buffer.concat([this.buffer, bytes]); if (!this.upgraded) { const end = this.buffer.indexOf('\r\n\r\n'); if (end < 0) return; assert.match(this.buffer.subarray(0,end).toString(), /^HTTP\/1.1 101/); this.buffer = this.buffer.subarray(end+4); this.upgraded = true; } while(this.buffer.length >= 2) { const opcode = this.buffer[0] & 15; let size = this.buffer[1] & 127, header = 2; if(size === 126) { if(this.buffer.length < 4) return; size = this.buffer.readUInt16BE(2); header=4; } if(size === 127) { if(this.buffer.length < 10) return; size=Number(this.buffer.readBigUInt64BE(2)); header=10; } if(this.buffer.length < header+size) return; const payload = this.buffer.subarray(header,header+size); this.buffer=this.buffer.subarray(header+size); if(opcode === 1) this.queue.push(JSON.parse(payload.toString())); if(opcode === 2) this.frames.push(Buffer.from(payload)); if(opcode === 2 && payload[0] === 3) { this.outputBytes += payload.length-5; this.tail=(this.tail+payload.subarray(5).toString()).slice(-2048); for (const waiter of [...this.outputWaiters]) if (this.tail.includes(waiter.text)) waiter.resolve(); } if(opcode === 9) this.send(10,payload); } }); } send(opcode,payload) { payload=Buffer.from(payload); const mask=crypto.randomBytes(4); const header=Buffer.alloc(payload.length < 126 ? 6 : 8); header[0]=128|opcode; if(payload.length < 126) header[1]=128|payload.length; else { header[1]=254; header.writeUInt16BE(payload.length,2); } mask.copy(header,header.length-4); const bytes=Buffer.from(payload);for(let i=0;i<bytes.length;i++) bytes[i]^=mask[i%4]; this.socket.write(Buffer.concat([header,bytes])); } frame(kind,channel,payload) { const header=Buffer.alloc(5);header[0]=kind;header.writeUInt32BE(channel,1); this.send(2,Buffer.concat([header,Buffer.from(payload)])); } async wait(predicate, label, timeout=12000) { const until=Date.now()+timeout; while(Date.now()<until) { if(this.error) throw this.error; if(predicate()) return; await delay(10); } throw Error('Timeout: '+label); } async paired() { await this.wait(()=>this.queue.some(x=>x.t==='pair/ok'),'silent pairing'); } waitOutput(text) { return new Promise((resolve, reject) => { const waiter = {text, resolve: () => { cleanup(); resolve(); }}; const timer = setTimeout(() => { cleanup(); reject(Error(`Missing output: ${text}`)); }, 10000); const cleanup = () => { clearTimeout(timer); this.outputWaiters = this.outputWaiters.filter(item => item !== waiter); }; this.outputWaiters.push(waiter); }); } async request(verb,args) { const reply = await this.requestRaw(verb,args); assert.equal(reply.t,'ok',JSON.stringify(reply)); return reply.result; } async requestRaw(verb,args) { const id=++this.sequence; this.send(1,JSON.stringify({t:'req',id,verb,args})); await this.wait(()=>this.queue.some(x=>x.id===id),'reply '+verb); const reply=this.queue.splice(this.queue.findIndex(x=>x.id===id),1)[0]; return reply; }}module.exports = {Client, delay};packages/slate-cli/test/terminal-restart-gate.cjsadded
// Real-process gate. Only disposable state, nodes and PTYs are touched.const fs = require('node:fs');const os = require('node:os');const path = require('node:path');const net = require('node:net');const assert = require('node:assert/strict');const {spawn} = require('node:child_process');const {Client, delay} = require('./helpers/terminal-client.cjs');const {relayServer} = require('./helpers/relay-peer.cjs');async function waitFor(test, label, milliseconds = 10000) { const deadline = Date.now() + milliseconds; while (Date.now() < deadline) { if (await test()) return; await delay(50); } throw Error(`Timeout: ${label}`);}function running(pid) { try { return fs.readFileSync(`/proc/${pid}/stat`, 'utf8').split(') ')[1][0] !== 'Z'; } catch { return false; }}async function refusedOwnerHandshake(endpoint, hello) { await new Promise((resolve, reject) => { const socket = net.connect(endpoint); const timer = setTimeout(() => { socket.destroy(); reject(Error('owner handshake did not close')); }, 3000); let bytes = 0; socket.on('connect', () => { const payload = Buffer.from(JSON.stringify(hello)); const header = Buffer.alloc(4); header[0] = 0x81; header[1] = 126; header.writeUInt16BE(payload.length, 2); socket.write(Buffer.concat([header, payload])); }); socket.on('data', data => { bytes += data.length; }); socket.on('error', reject); socket.on('close', () => { clearTimeout(timer); if (bytes === 0) resolve(); else reject(Error('owner accepted an invalid identity/version')); }); });}(async () => { const binary = path.resolve(process.argv[2] || process.env.SLATE_TEST_BIN); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'slate-restart-')); const clients = [], children = [], shells = new Set(), owners = new Set(); const state = path.join(root, 'slate'); fs.mkdirSync(state, {mode: 0o700}); fs.mkdirSync(path.join(root, 'app')); fs.writeFileSync(path.join(root, 'app/index.html'), 'Disposable lifecycle test'); fs.writeFileSync(path.join(state, 'enclave-node.json'), JSON.stringify({ version: 2, origin: 'http://127.0.0.1:9', 'websocket-url': 'ws://127.0.0.1:9/ws', 'node-name': 'restart-test', 'transport-nick': 'restart-test', 'bot-id': 'fixture', token: '0'.repeat(64) }), {mode: 0o600}); const reservation = net.createServer(); await new Promise(resolve => reservation.listen(0, '127.0.0.1', resolve)); const port = reservation.address().port; await new Promise(resolve => reservation.close(resolve)); async function start() { const log = fs.openSync(path.join(root, `node-${children.length}.log`), 'a'); const child = spawn(binary, ['node', 'run', '--port', String(port), '--app-dir', path.join(root, 'app')], { cwd: root, env: {...process.env, XDG_STATE_HOME: root, XDG_CONFIG_HOME: path.join(root, 'config'), SLATE_SESSION_TTL: '4'}, stdio: ['ignore', log, 'pipe'] }); fs.closeSync(log); children.push(child); child.stderr.on('data', data => fs.appendFileSync(path.join(root, 'stderr.log'), data)); child.finished = new Promise(resolve => child.once('exit', resolve)); await waitFor(async () => { assert.equal(child.exitCode, null, 'node remains alive'); try { return (await fetch(`http://127.0.0.1:${port}/node/health`, {signal: AbortSignal.timeout(500)})).ok; } catch { return false; } }, 'node health'); return child; } async function connect() { const client = new Client(port); clients.push(client); await client.paired(); return client; } async function stop(child, signal) { child.kill(signal); await child.finished; } async function relayCheck(principal, sid, permitted) { const relay = await relayServer(); const next = relay.next(); const worker = spawn(binary, ['node', '_stream', relay.url, principal, 'disposable-ticket', path.join(state, 'holders', `h-test-${children.length}.sock`)], { cwd: root, env: {...process.env, XDG_STATE_HOME: root, SLATE_SESSION_TTL: '4'}, stdio: 'ignore' }); children.push(worker); worker.finished = new Promise(resolve => worker.once('exit', resolve)); try { const peer = await Promise.race([next, delay(10000).then(() => { throw Error('relay did not connect'); })]); await peer.wait(() => peer.ticket, 'worker ticket'); const rows = (await peer.request('session/list', {})).sessions; assert.equal(rows.some(row => row.sid === sid), permitted); const attached = await peer.requestRaw('session/attach', {sid}); assert.equal(attached.t, permitted ? 'ok' : 'err'); if (permitted) { peer.frame(2, attached.result.chan, 'relay-survival\r'); await peer.wait(() => peer.tail.includes('REPLY:relay-survival'), 'relay input reaches original shell'); await peer.request('fs/stat', {path: path.join(root, 'app/index.html')}); } peer.socket.destroy(); await waitFor(() => worker.exitCode !== null || worker.signalCode !== null, 'empty transport worker retires'); } finally { await relay.close(); if (worker.exitCode === null && worker.signalCode === null) await stop(worker, 'SIGKILL'); } } try { let node = await start(), client = await connect(); const opened = await client.request('session/open', {argv: ['/bin/sh', '-c', 'printf "SHELL-PID:%s\\n" "$$"; while read line; do printf "REPLY:%s\\n" "$line"; done'], cols: 80, rows: 24}); await client.wait(() => /SHELL-PID:(\d+)/.test(client.tail), 'shell PID'); const pid = Number(client.tail.match(/SHELL-PID:(\d+)/)[1]); shells.add(pid); const ownerPid = Number(fs.readFileSync(`/proc/${pid}/stat`, 'utf8').split(') ')[1].split(' ')[1]); if (ownerPid !== node.pid) owners.add(ownerPid); const latency = []; for (let i = 0; i < 64; i++) { const reply = client.waitOutput(`REPLY:latency-${i}\r\n`); const before = performance.now(); client.frame(2, opened.chan, `latency-${i}\r`); await reply; latency.push(performance.now() - before); } latency.sort((a, b) => a - b); console.log(`Local input/reply smoke measurement (64 lines): p50=${latency[32].toFixed(2)}ms p95=${latency[60].toFixed(2)}ms`); if (process.env.SLATE_LATENCY_ONLY === '1') { client.frame(8, opened.chan, Buffer.alloc(0)); await waitFor(() => !running(pid), 'benchmark shell closes'); return; } assert.notEqual(ownerPid, node.pid, 'PTY has an independent owner'); const ownerDir = path.join(state, 'pty'); assert.equal(fs.statSync(ownerDir).mode & 0o777, 0o700); const endpoint = path.join(ownerDir, fs.readdirSync(ownerDir).find(name => name.endsWith('.sock'))); const identityFile = path.join(state, 'node-direct-identity.json'); const identity = fs.existsSync(identityFile) ? JSON.parse(fs.readFileSync(identityFile, 'utf8'))['key-id'] : 'local'; await refusedOwnerHandshake(endpoint, {version: 1, identity: 'wrong-node', mode: 'stream', origin: 'phone'}); await refusedOwnerHandshake(endpoint, {version: 99, identity, mode: 'stream', origin: 'phone'}); console.log('PASS private owner directory and refusal of invalid identity/protocol'); const initial = (await client.request('session/list', {})).sessions.find(row => row.sid === opened.sid); const fdCount = () => fs.readdirSync(`/proc/${ownerPid}/fd`).length; const baselineFds = fdCount(); for (let i = 0; i < 24; i++) { const disposable = i % 2 ? client : await connect(); const extra = await disposable.request('session/open', {argv: ['/bin/sh', '-c', 'read line'], cols: 80, rows: 24}); disposable.frame(8, extra.chan, Buffer.alloc(0)); await waitFor(async () => !(await client.request('session/list', {})).sessions.some(row => row.sid === extra.sid), 'closed session leaves catalog'); if (disposable !== client) disposable.socket.destroy(); } await waitFor(() => fdCount() <= baselineFds + 1, 'owner descriptors settle after repeated open/close'); assert.equal((await client.request('session/list', {})).sessions.length, 1); console.log(`PASS 24 open/close cycles; owner descriptors ${baselineFds} -> ${fdCount()}`); await client.request('session/detach', {sid: opened.sid}); await relayCheck(`http://127.0.0.1:${port}`, opened.sid, true); await relayCheck('foreign-principal', opened.sid, false); await client.request('session/attach', {sid: opened.sid}); assert.ok(running(pid)); console.log('PASS relay reattaches same shell, foreign principal is refused, resource RPC works, empty transport workers retire'); for (const signal of ['SIGTERM', 'SIGKILL', 'SIGTERM']) { await stop(node, signal); client.socket.destroy(); assert.ok(running(pid), `PTY survives node ${signal}`); node = await start(); client = await connect(); const row = (await client.request('session/list', {})).sessions.find(row => row.sid === opened.sid); assert.ok(row, 'same session is discovered by replacement node'); assert.equal(row.created, initial.created, 'creation time is retained'); const attached = await client.request('session/attach', {sid: opened.sid}); assert.equal(attached.created, initial.created, 'attach reports original creation time'); assert.ok(attached.age >= row.age, 'attach reports retained age'); const nonce = `${signal}-${Date.now()}`; client.frame(2, attached.chan, `${nonce}\r`); await client.wait(() => client.tail.includes(`REPLY:${nonce}`), 'original shell accepts input'); assert.ok(running(pid)); console.log(`PASS same PID, SID, creation time and working input after ${signal}`); } await client.request('session/detach', {sid: opened.sid}); await stop(node, 'SIGKILL'); client.socket.destroy(); await waitFor(() => !running(pid), 'detached shell expires while node is down', 25000); console.log('PASS owner reaps detached PTY without a main node'); await waitFor(() => { const dir = path.join(state, 'pty'); return !fs.existsSync(dir) || !fs.readdirSync(dir).some(name => name.endsWith('.sock')); }, 'empty owner retires', 20000); console.log('PASS empty owner retires and removes its endpoint'); assert.ok(!running(ownerPid), 'retired owner is no longer running'); node = await start(); client = await connect(); const doomed = await client.request('session/open', {argv: ['/bin/sh', '-c', 'printf "DOOMED:%s\\n" "$$"; read line'], cols: 80, rows: 24}); await client.wait(() => /DOOMED:(\d+)/.test(client.tail), 'second owner shell'); const doomedPid = Number(client.tail.match(/DOOMED:(\d+)/)[1]); shells.add(doomedPid); const doomedOwner = Number(fs.readFileSync(`/proc/${doomedPid}/stat`, 'utf8').split(') ')[1].split(' ')[1]); owners.add(doomedOwner); process.kill(doomedOwner, 'SIGKILL'); await waitFor(() => !running(doomedPid), 'owner crash ends its PTY'); await client.wait(() => client.frames.some(frame => frame[0] === 9 && frame.readUInt32BE(1) === doomed.chan), 'owner failure is reported'); const refused = await client.requestRaw('session/attach', {sid: doomed.sid}); assert.equal(refused.t, 'err'); assert.equal(refused.code, 'no-session'); assert.equal((await client.request('session/list', {})).sessions.length, 0); await waitFor(() => !fs.existsSync(`/proc/${doomedOwner}`), 'main node reaps exited owner'); console.log('PASS owner crash is reported, child ends, stale attach refuses without a replacement shell, owner is reaped'); } finally { for (const client of clients) client.socket.destroy(); for (const child of children) if (child.exitCode === null && child.signalCode === null) await stop(child, 'SIGKILL'); for (const pid of shells) if (running(pid)) { try { process.kill(pid, 'SIGKILL'); } catch {} } for (const pid of owners) if (running(pid)) { try { process.kill(pid, 'SIGKILL'); } catch {} } console.log(`Lifecycle evidence: ${root}`); }})().catch(error => { console.error(error); process.exitCode = 1; });packages/slate-cli/test/test-agent-ceiling.sgladded
;;; A1' — the agent session's authority ceiling.;;;;;; The arms here test the REAL computation from (slate-cli agent ceiling),;;; not a reimplementation of it. A test that redid the arithmetic would;;; agree with itself by construction.;;;;;; Two rules shape the file, the same ones the grant algebra's own gate;;; uses. Every arm prints the size of what it inspected, because a property;;; that holds over zero cases is indistinguishable from one that holds. And;;; every checker is run against a deliberately wrong implementation in this;;; same file, because a checker that has never returned a violation has not;;; been shown able to.(import (sigil test) (sigil io) (sigil list) (sigil string) (sigil process) (sigil grant) (sigil fs) (sigil system grant) (slate-cli agent tools) (slate-cli agent ceiling))(define HOME (or (getenv "HOME") "/home/nobody"))(define (under-home p) (string-append HOME "/" p));; The node postures that actually exist in config.sgl, not invented ones.(define postures (list (cons "owner" (list "fs:rw:~" "fs:ro:/" "exec:full" "pty:on")) (cons "default" (list "fs:rw:~")) (cons "useslate" (list "fs:rw:~" "exec:full" "pty:on")) (cons "empty" '())))(define workspaces (list (cons "under-home" (under-home "proj")) (cons "home-itself" HOME) (cons "outside-home" "/srv/project")))(define exec-flags (list (cons "noexec" #f) (cons "exec" #t)));; Every (posture x workspace x exec?) combination.(define cases (let loop ((ps postures) (acc '())) (if (null? ps) (reverse acc) (loop (cdr ps) (let loop2 ((ws workspaces) (acc acc)) (if (null? ws) acc (loop2 (cdr ws) (let loop3 ((es exec-flags) (acc acc)) (if (null? es) acc (loop3 (cdr es) (cons (list (string-append (car (car ps)) "/" (car (car ws)) "/" (car (car es))) (cdr (car ps)) (cdr (car ws)) (cdr (car es))) acc)))))))))))(define (specs->g specs) (grant-from-specs (map expand-for-test specs)));; The module expands `~` internally; the test must do the same to build the;; comparison value, and doing it here rather than importing a private helper;; keeps the module's surface honest.(define (expand-for-test s) (let ((parts (string-split s ":"))) (if (and (>= (length parts) 3) (string=? (car parts) "fs")) (let ((p (string-join (cddr parts) ":"))) (string-append "fs:" (cadr parts) ":" (cond ((string=? p "~") HOME) ((string-starts-with? p "~/") (string-append HOME (substring p 1 (string-length p)))) (else p)))) s)));; ============================================================;; Checkers, each taking the ceiling function under test;; ============================================================;; THE LOAD-BEARING ARM. The node must provably cover every ceiling it;; hands out. This is the property that makes the whole change worth;; making: before A1' a session could hold authority the node did not.(define (count-not-covered-by-node ceiling-fn) (let loop ((rest cases) (checked 0) (bad 0)) (if (null? rest) (list checked bad) (let* ((c (car rest)) (node (cadr c)) (wanted (child-narrowing-specs (caddr c) (cadddr c))) (granted (ceiling-fn node wanted))) (loop (cdr rest) (+ checked 1) (if (grant-covers? (specs->g node) (specs->g granted)) bad (+ bad 1)))))));; A ceiling must also never exceed what the session ASKED for: the meet is;; a lower bound of both inputs, not just of the node's root.(define (count-not-covered-by-request ceiling-fn) (let loop ((rest cases) (checked 0) (bad 0)) (if (null? rest) (list checked bad) (let* ((c (car rest)) (wanted (child-narrowing-specs (caddr c) (cadddr c))) (granted (ceiling-fn (cadr c) wanted))) (loop (cdr rest) (+ checked 1) (if (grant-covers? (specs->g wanted) (specs->g granted)) bad (+ bad 1)))))));; The ceiling must round-trip through the argv handoff unchanged. This is;; A1's OWN corpus (workspace paths, harness argv) rather than the algebra's,;; because the module's suite can be green while this consumer's assumption;; is false.(define (count-roundtrip-violations ceiling-fn) (let loop ((rest cases) (checked 0) (bad 0)) (if (null? rest) (list checked bad) (let* ((c (car rest)) (wanted (child-narrowing-specs (caddr c) (cadddr c))) (granted (ceiling-fn (cadr c) wanted)) ;; through the wire and back: argv out, specs in (args (grant-args granted)) (back (let loop2 ((a args) (acc '())) (cond ((null? a) (reverse acc)) ((and (string=? (car a) "--grant") (pair? (cdr a))) (loop2 (cddr a) (cons (cadr a) acc))) (else (loop2 (cdr a) acc)))))) (loop (cdr rest) (+ checked 1) (if (grant=? (specs->g granted) (specs->g back)) bad (+ bad 1)))))));; ============================================================;; Deliberately wrong ceilings, as positive controls;; ============================================================;; Ignores the node entirely: the PRE-A1' behaviour, and exactly the defect;; this change closes. Must violate the node-covers arm.(define (ceiling-ignoring-node node-specs wanted-specs) wanted-specs);; Hands back the node's whole root: widens past what was asked for. Must;; violate the request-covers arm.(define (ceiling-whole-root node-specs wanted-specs) node-specs)(define node-real (count-not-covered-by-node compute-ceiling))(define node-ctl (count-not-covered-by-node ceiling-ignoring-node))(define req-real (count-not-covered-by-request compute-ceiling))(define req-ctl (count-not-covered-by-request ceiling-whole-root))(define rt-real (count-roundtrip-violations compute-ceiling))(println "DENOM ceiling-cases ~a" (length cases))(println "DENOM node-covers-checked ~a" (car node-real))(println "DENOM request-covers-checked ~a" (car req-real))(println "DENOM roundtrip-checked ~a" (car rt-real))(println "CONTROL ignoring-node-violations ~a" (cadr node-ctl))(println "CONTROL whole-root-violations ~a" (cadr req-ctl))(test-group "A1' ceiling — instrument controls" (test "the corpus is not empty" (assert-true (> (length cases) 0))) (test "the node-covers checker CAN report a violation" ;; The wrong implementation here is the PRE-A1' behaviour: mint the ;; ceiling from the request and ignore the node. If this reported zero, ;; the arm could not tell the old defect from the fix. (assert-true (> (cadr node-ctl) 0))) (test "the request-covers checker CAN report a violation" (assert-true (> (cadr req-ctl) 0))))(test-group "A1' ceiling — the properties" (test "the node provably covers every ceiling it hands out" (assert-equal 0 (cadr node-real))) (test "no ceiling exceeds what the session asked for" (assert-equal 0 (cadr req-real))) (test "the ceiling survives the argv handoff unchanged" (assert-equal 0 (cadr rt-real))))(test-group "A1' ceiling — the cases that MUST NOT change" ;; The common deployment: owner posture, workspace under HOME. This is ;; what a local user actually runs, and A1' must not touch it. (test "owner posture with a workspace under HOME is unchanged" (let* ((node (cdr (assoc "owner" postures))) (ws (under-home "proj")) (wanted (child-narrowing-specs ws #t))) (assert-true (grant=? (specs->g wanted) (specs->g (compute-ceiling node wanted)))))) (test "and it is not reported as a reduction" (let ((node (cdr (assoc "owner" postures)))) (assert-false (ceiling-reduced? node (child-narrowing-specs (under-home "proj") #t))))))(test-group "A1' ceiling — the cases that SHOULD change, named individually" ;; Each is asserted BY NAME so an unintended change cannot hide among the ;; intended ones. (test "a workspace outside HOME loses WRITE under the owner posture" ;; The node holds fs:ro:/ out there, so read survives and write does not. (let* ((node (cdr (assoc "owner" postures))) (granted (compute-ceiling node (child-narrowing-specs "/srv/project" #f)))) (assert-true (grant-covers? (specs->g granted) (grant-from-specs '("fs:ro:/srv/project")))) (assert-false (grant-covers? (specs->g granted) (grant-from-specs '("fs:rw:/srv/project")))))) (test "the restricted posture grants no exec even when the session asks" (let* ((node (cdr (assoc "default" postures))) (granted (compute-ceiling node (child-narrowing-specs (under-home "p") #t)))) (assert-false (grant-covers? (specs->g granted) (grant-from-specs '("exec:full")))))) (test "a node holding nothing yields an empty ceiling" (assert-equal '() (compute-ceiling '() (child-narrowing-specs (under-home "p") #t)))) (test "a reduction is reported as one" (assert-true (ceiling-reduced? (cdr (assoc "default" postures)) (child-narrowing-specs "/srv/project" #t)))))(test-group "A1' ceiling — an empty ceiling DENIES, it does not fall back" ;; The case where the node holds nothing overlapping the request. The ;; failure mode guarded here is a silent WIDENING: the sidecar decides ;; between the handed-down ceiling and the legacy self-minted one on ;; `(if grant-specs ...)`, and `'()` is TRUTHY, so an empty ceiling ;; correctly takes the handed-down branch and permits nothing. Rewriting ;; that test as `(pair? grant-specs)` would send the empty ceiling to the ;; fallback and silently restore the un-intersected ceiling, in exactly ;; the situation where the node granted nothing. (test "a table built from no specs permits no read" (assert-false (grant-check (grants-from-specs '()) 'fs-read "/tmp"))) (test "a table built from no specs permits no exec" (assert-false (grant-check (grants-from-specs '()) 'exec "ls"))) (test "and a table built from real specs DOES permit, so the arm above is not vacuous" ;; Without this, "permits nothing" would also be satisfied by ;; grants-from-specs being broken for every input. (let ((dir (make-temp-directory))) (assert-true (grant-check (grants-from-specs (list (string-append "fs:rw:" dir))) 'fs-read dir)))))(test-group "A1' ceiling — the handoff shape" (test "one spec per argv element, never packed" (assert-equal '("--grant" "fs:rw:/w" "--grant" "exec:full") (grant-args '("fs:rw:/w" "exec:full")))) (test "an empty ceiling produces no arguments at all" (assert-equal '() (grant-args '()))) (test "a principal maps to its origin" (assert-equal "https://useslate.org" (origin-of-principal "web:https://useslate.org"))))(run-tests)packages/slate-cli/test/test-enclave-node-init.sglmodified
(assert-equal client-ticket (list-ref msg 3)) (assert-equal node-ticket (list-ref msg 4))))) (test "node relay ticket preserves authoritative pairing descriptors" (let* ((fields '("alice" "g7" "ct" "nt" "30" "11" "ck" "1" "cpub" "22" "ak" "2" "apub")) (line (string-append ":enclave STREAM-TICKET " (string-join fields " "))) (msg (node-session-parse-wire-message line))) (assert-equal 'stream-ticket-message (car msg)) (assert-equal fields (cdr msg)))) (test "long DIRECT-TICKET keys bypass recursive IRC scanning" (let* ((caller-key (string-repeat "c:?[]{}+/=" 18)) (acceptor-key (string-repeat "a:{}[]?+/=" 18)) (line (string-append ":enclave DIRECT-TICKET caller phone req-7 " "ws://10.11.0.9:32644/direct " caller-key " " acceptor-key " 15")) (msg (node-session-parse-wire-message line))) (assert-equal 'direct-ticket-message (car msg)) (assert-equal "caller" (cadr msg)) (assert-equal caller-key (list-ref msg 5)) (assert-equal acceptor-key (list-ref msg 6)))) (test "extended DIRECT-TICKET preserves both authenticated key descriptors" (let* ((line (string-append ":enclave DIRECT-TICKET caller phone req-8 ws://10.0.0.2/x ckey akey 15 " "41 caller-key 2 caller-public 42 acceptor-key 3 acceptor-public")) (msg (node-session-parse-wire-message line))) (assert-equal 'direct-ticket-message (car msg)) (assert-equal 15 (length (cdr msg))) (assert-equal "41" (list-ref msg 8)) (assert-equal "acceptor-public" (list-ref msg 15)))) (test "relayed stream ticket bypasses recursive IRC scanning" (let* ((ticket (string-repeat "t:?[]{}+/=" 24)) (payload (string-append "{\"ticket\":\"" ticket "\",\"expires-in\":30}")) (line (string-append ":enclave ENCLAVE-RPC phone g1 stream/ticket :" payload)) (msg (node-session-parse-wire-message line))) (assert-equal 'relay-ticket-message (car msg)) (assert-equal "phone" (cadr msg)) (assert-equal "g1" (list-ref msg 2)) (assert-equal payload (list-ref msg 4))))(test-group "Enclave node init contract" (test "pending reply pins approval URL to the configured HTTPS origin" (let* ((msg (parse-irc-message (msg (parse-irc-message (string-append "@+enclave/result.status=ok;+enclave/result.node-name=workstation;" "+enclave/result.transport-nick=slate-node-123456789abc;" "+enclave/result.bot-id=42;+enclave/result.token=" token " PAIR-REQUEST workstation OK"))) " PAIR-REQUEST slate-node-123456789abc OK"))) (completed (node-init-complete-result msg)) (record (and completed (node-init-record "chat.example" completed)))) (assert-true completed) (assert-equal (dict-ref record websocket-url: "") "wss://chat.example/ws") (assert-equal (node-init-stream-websocket-url record) "wss://chat.example/ws") (assert-equal (dict-ref record node-name: "") "workstation") (assert-equal (dict-ref record transport-nick: "") "slate-node-123456789abc") (assert-equal (dict-ref (node-init-record<-json (node-init-record->json record)) token: "") token) (assert-false (parse-irc-message "@+enclave/result.status=ok;+enclave/result.node-name=workstation PAIR-REQUEST workstation OK"))))) (test "a node identity may route relay streams through a distinct ingress" (let* ((token (make-string 64 #\e)) (record (node-init-record<-json (string-append "{\"version\":2,\"origin\":\"https://chat.example\"," "\"websocket-url\":\"wss://chat.example/ws\"," "\"stream-websocket-url\":\"wss://stream.example/ws\"," "\"node-name\":\"machine\",\"transport-nick\":\"machine\"," "\"bot-id\":\"1\",\"token\":\"" token "\"}")))) (assert-equal (node-init-stream-websocket-url record) "wss://stream.example/ws"))) (test "structured PAIR-REQUEST failures expose only the reason code" (assert-equal (node-init-failure(define *inbound* '())(define *sent* '())(define *connections* 0)(define *blocking-receives* 0)(define *nowait-receives* 0)(define (enqueue! line) (set! *inbound* (append *inbound* (list (parse-irc-message line)))))(define mock-transport (set! *sent* (cons (apply make-irc-command command params) *sent*))) register: (lambda (ws) (set! *sent* (cons "REGISTER" *sent*))) receive: (lambda (ws) (set! *blocking-receives* (+ *blocking-receives* 1)) (if (null? *inbound*) #f (let ((msg (car *inbound*))) (set! *inbound* (cdr *inbound*)) msg))) receive-nowait: (lambda (ws) (set! *nowait-receives* (+ *nowait-receives* 1)) (if (null? *inbound*) #f (let ((msg (car *inbound*))) (set! *inbound* (cdr *inbound*)) msg))) close: (lambda (ws) #t) })(test "node session waits for approval, persists, then proves a fresh SASL login" (set! *inbound* '()) (set! *sent* '()) (set! *connections* 0) (set! *blocking-receives* 0) (set! *nowait-receives* 0) (node-session-set-transport! mock-transport) (let ((pending-seen #f) (saved #f) (ready #f) (failure #f) (token (make-string 64 #\f))) (assert-true pending-seen) (enqueue! (string-append "@+enclave/result.status=ok;+enclave/result.node-name=workstation;" "+enclave/result.transport-nick=slate-node-123456789abc;" "+enclave/result.bot-id=42;+enclave/result.token=" token " PAIR-REQUEST workstation OK")) " PAIR-REQUEST slate-node-123456789abc OK")) (node-session-poll!) (assert-equal *connections* 2) (assert-equal (dict-ref saved token: "") token) (assert-equal (dict-ref saved node-name: "") "workstation") (assert-equal (dict-ref saved transport-nick: "") "slate-node-123456789abc") (node-session-poll!) (enqueue! ":enclave CAP workstation LS :sasl") (node-session-poll!) (enqueue! ":enclave CAP workstation ACK :sasl") (node-session-poll!) (enqueue! ":enclave CAP slate-node-123456789abc LS :sasl") (node-session-poll!) (enqueue! ":enclave CAP slate-node-123456789abc ACK :sasl") (node-session-poll!) (enqueue! "AUTHENTICATE +") (node-session-poll!) (enqueue! ":enclave 903 workstation :SASL authentication successful") (enqueue! ":enclave 903 slate-node-123456789abc :SASL authentication successful") (node-session-poll!) (enqueue! ":enclave 001 workstation :Welcome") (node-session-poll!) (enqueue! ":enclave 001 slate-node-123456789abc :Welcome") (node-session-poll!) (assert-true ready) (assert-false failure) (assert-true (node-session-done?)) (assert-true (member "REGISTER" *sent*))) (assert-true (member "REGISTER" *sent*)) (node-session-send-command! "DIRECT-OPEN" "target-node" "g1") (assert-true (member "DIRECT-OPEN target-node g1" *sent*)) ;; The direct gateway shares this supervisor during authentication too. ;; No auth phase may block the pump while Enclave is unavailable. (node-session-poll!) (assert-equal 0 *blocking-receives*) (assert-true (> *nowait-receives* 0)) ;; Losing Enclave must permit the daemon supervisor to reopen only this ;; child control session without rebuilding listeners or re-enrolling. (set! *inbound* (list 'closed)) (node-session-poll!) (assert-equal 'failed (node-session-phase)) (assert-true (node-session-reconnect!)) (assert-equal 3 *connections*) (assert-equal 'auth-connecting (node-session-phase))) (node-session-close!) (node-session-reset-transport!))(test "synchronous reconnect refusal never probes a null socket" (let ((failure #f)) (node-session-set-transport! #{ connect: (lambda (url nick) #f) connected?: (lambda (ws) (error "null socket was probed")) send: (lambda args #f) command: (lambda args #f) register: (lambda args #f) receive: (lambda args #f) receive-nowait: (lambda args #f) close: (lambda args #t) }) (node-session-resume! #{ origin: "https://chat.example" transport-nick: "slate-node-test" token: (make-string 64 #\f) } (lambda (record) #t) (lambda (message) #t) (lambda (reason) (set! failure reason))) (node-session-poll!) (assert-equal "CONNECTION_FAILED" failure) (assert-equal 'failed (node-session-phase))) (node-session-close!) (node-session-reset-transport!))packages/slate-cli/test/test-enclave-remote.sglmodified
(response (remote-handle-rpc (make-config) msg))) (assert-equal '("STREAM-TICKET" "alice" "19") response))) (test "resource open routes without a session holder" (let* ((route #f) (response (remote-handle-rpc (make-config) (parse-irc-message ":enclave.local ENCLAVE-RPC alice 21 resource/open :{\"id\":21,\"args\":{}}") (lambda (sender request-id hint) (set! route (list sender request-id hint)))))) (assert-equal '("STREAM-TICKET" "alice" "21") response) (assert-equal (list "alice" "21" resource:) route))) (test "node keeps its ticket and returns only the client half" (let* ((started '()) (response (remote-handle-ticket (parse-irc-message ":enclave.local STREAM-TICKET alice 19 client-secret node-secret 30") (lambda (client sid ticket) (lambda (client sid ticket metadata) (set! started (list client sid ticket)))))) (assert-equal '("alice" "19" "node-secret") started) (assert-equal "ENCLAVE-RPC" (car response)) (remote-handle-ticket (parse-irc-message ":enclave.local STREAM-TICKET alice 20 client-marker node-marker 30") (lambda (client sid ticket) #t))) (lambda (client sid ticket metadata) #t))) (wire (car (reverse response)))) (assert-true (string-contains? wire "client-marker")) (assert-false (string-contains? wire "node-marker")))) (test "node relay response forwards only public pairing descriptors" (let* ((fields '("node-a" "g9" "client-secret" "node-secret" "30" "11" "ck" "1" "cpub" "22" "ak" "2" "apub")) (response (remote-handle-ticket (cons 'stream-ticket-message fields) (lambda (client request ticket metadata) #t))) (wire (car (reverse response)))) (assert-true (string-contains? wire "pairing-descriptors")) (assert-true (string-contains? wire "cpub")) (assert-true (string-contains? wire "apub")) (assert-false (string-contains? wire "node-secret")))) )packages/slate-cli/test/test-enclave-stream-resource.sgladded
;;; Relay holder Resource-plane coverage. This exercises the production;;; decode/dispatch/deferred-state/send adapter without an Enclave server.(import (sigil test) (sigil core) (sigil io) (sigil fs) (sigil path) (sigil json) (sigil async) (sigil wire) (sigil websocket frame) (slate-cli config) (slate-cli wire) (slate-cli enclave-stream))(define ROOT (make-temp-directory))(define CLIENT "relay-test-node")(define CONFIG (make-config grants: (list (cons CLIENT (list (string-append "fs:rw:" ROOT))))))(define (make-capture) (let ((frames (vector '()))) (cons frames (lambda (encoded) (let ((frame (frame-decode-result-frame (decode-frame encoded)))) (when frame (vector-set! frames 0 (append (vector-ref frames 0) (list frame)))))))))(define (capture-frames capture) (vector-ref (car capture) 0))(define (capture-send capture) (cdr capture))(define (binary-node-frames capture) (let loop ((frames (capture-frames capture)) (out '())) (cond ((null? frames) (reverse out)) ((= (ws-frame-opcode (car frames)) opcode-binary) (let ((decoded (decode-stream-frame (ws-frame-payload (car frames))))) (loop (cdr frames) (if decoded (cons decoded out) out)))) (else (loop (cdr frames) out)))))(define (binary-frame-for capture type id) (let loop ((frames (binary-node-frames capture))) (and (pair? frames) (if (and (= (stream-frame-type (car frames)) type) (= (stream-frame-id (car frames)) id)) (car frames) (loop (cdr frames))))))(define (controls capture) (let loop ((frames (capture-frames capture)) (out '())) (cond ((null? frames) (reverse out)) ((= (ws-frame-opcode (car frames)) opcode-text) (loop (cdr frames) (cons (decode-control (utf8->string (ws-frame-payload (car frames)))) out))) (else (loop (cdr frames) out)))))(define (control-for capture type id) (let loop ((values (controls capture))) (and (pair? values) (let ((value (car values))) (if (and (equal? (dict-ref value t: #f) type) (equal? (dict-ref value id: #f) id)) value (loop (cdr values)))))))(define (event-for capture sub) (let loop ((values (controls capture))) (and (pair? values) (let ((value (car values))) (if (and (equal? (dict-ref value t: #f) "ev") (equal? (dict-ref value sub: #f) sub)) value (loop (cdr values)))))))(define (request! holder value) (enclave-stream-test-text! holder (json-encode value)))(test "resource-only streams reject session creation and still read files" (let* ((capture (make-capture)) (holder (make-enclave-stream-test-holder CONFIG CLIENT (capture-send capture) #t))) (request! holder #{ t: "req" id: 1 verb: "session/open" args: #{ kind: "pty" } }) (assert-equal "unsupported" (dict-ref (control-for capture "err" 1) code: #f)) (request! holder #{ t: "req" id: 2 verb: "fs/stat" args: #{ path: ROOT } }) (assert-true (control-for capture "ok" 2)) (enclave-stream-test-close! holder)))(test "relay holder completes a deferred bulk write once" (let* ((capture (make-capture)) (holder (make-enclave-stream-test-holder CONFIG CLIENT (capture-send capture))) (path (path-join ROOT "bulk.txt")) (payload (wire-encode (string->utf8 "relay bulk body")))) (request! holder #{ t: "req" id: 41 verb: "fs/write-file" args: #{ path: path bulk: #t } }) (assert-false (control-for capture "ok" 41)) (enclave-stream-test-binary! holder (encode-stream-frame frame-bulk 41 payload)) (assert-true (control-for capture "ok" 41)) (assert-equal "relay bulk body" (read-file-string path)) ;; The authority slot was consumed before dispatch; replay fails closed. (enclave-stream-test-binary! holder (encode-stream-frame frame-bulk 41 payload)) (assert-equal "no-pending-bulk" (dict-ref (control-for capture "err" 41) code: #f)) (enclave-stream-test-close! holder)))(test "relay holder rejects an unmatched bulk body and remains usable" (let* ((capture (make-capture)) (holder (make-enclave-stream-test-holder CONFIG CLIENT (capture-send capture)))) (enclave-stream-test-binary! holder (encode-stream-frame frame-bulk 99 (wire-encode (string->utf8 "x")))) (assert-equal "no-pending-bulk" (dict-ref (control-for capture "err" 99) code: #f)) (request! holder #{ t: "req" id: 100 verb: "fs/stat" args: #{ path: ROOT } }) (assert-true (control-for capture "ok" 100)) (enclave-stream-test-close! holder)))(test "relay holder streams an atomic upload with receiver credit" (let* ((capture (make-capture)) (holder (make-enclave-stream-test-holder CONFIG CLIENT (capture-send capture))) (path (path-join ROOT "stream-upload.bin")) (body (bytevector 9 8 7 6 5))) (write-file-string path "old") (request! holder #{ t: "req" id: 51 verb: "fs/write-file" args: #{ path: path transfer: #t size: 5 } }) (assert-equal "upload" (dict-ref (dict-ref (control-for capture "ok" 51) transfer: #{}) direction: #f)) (assert-true (binary-frame-for capture frame-transfer-credit 51)) (enclave-stream-test-binary! holder (encode-transfer-batch 51 0 body)) (assert-equal "old" (read-file-string path)) (enclave-stream-test-binary! holder (encode-transfer-end 51 1 5)) (assert-true (control-for capture "ok" 51)) (assert-true (equal? body (read-file-bytes path))) (enclave-stream-test-close! holder)))(test "relay holder download parks until credit then sends batch and end" (let* ((capture (make-capture)) (holder (make-enclave-stream-test-holder CONFIG CLIENT (capture-send capture))) (path (path-join ROOT "stream-download.bin"))) (write-file-bytes path (bytevector 1 3 5 7)) (request! holder #{ t: "req" id: 52 verb: "fs/read-file" args: #{ path: path transfer: #t } }) (assert-equal "download" (dict-ref (dict-ref (control-for capture "ok" 52) transfer: #{}) direction: #f)) (assert-false (binary-frame-for capture frame-batch 52)) (enclave-stream-test-binary! holder (encode-transfer-credit 52 4)) (let* ((batch-frame (binary-frame-for capture frame-batch 52)) (batch (and batch-frame (decode-transfer-batch batch-frame)))) (assert-true batch) (assert-equal (bytevector 1 3 5 7) (dict-ref batch bytes: #f))) (assert-true (binary-frame-for capture frame-end 52)) (enclave-stream-test-close! holder)))(test "relay holder emits watch events and unwatch stops the subscription" (with-async (let* ((capture (make-capture)) (holder (make-enclave-stream-test-holder CONFIG CLIENT (capture-send capture))) (watched (path-join ROOT "watched")) (path (path-join watched "changed.txt"))) (ensure-directory watched) (request! holder #{ t: "req" id: 61 verb: "fs/watch" args: #{ path: watched } }) (assert-true (control-for capture "ok" 61)) (write-file-string path "changed") (let loop ((tries 40)) (unless (or (event-for capture 61) (<= tries 0)) (sleep 0.1) (loop (- tries 1)))) (assert-true (event-for capture 61)) (request! holder #{ t: "req" id: 62 verb: "fs/unwatch" args: #{ sub: 61 } }) (assert-true (control-for capture "ok" 62)) (enclave-stream-test-close! holder))))(test "relay holder applies the canonical grant boundary to deferred state" (let* ((capture (make-capture)) (holder (make-enclave-stream-test-holder CONFIG CLIENT (capture-send capture)))) (request! holder #{ t: "req" id: 71 verb: "fs/write-file" args: #{ path: "/etc/slate-relay-must-not-write" bulk: #t } }) ;; Deferred writes intentionally authorize at body completion, through ;; dispatch-bulk-write, so no path decision is duplicated in the adapter. (enclave-stream-test-binary! holder (encode-stream-frame frame-bulk 71 (wire-encode (string->utf8 "must not land")))) (assert-equal "grant-denied" (dict-ref (control-for capture "err" 71) code: #f)) (request! holder #{ t: "req" id: 72 verb: "fs/watch" args: #{ path: "/etc" } }) (assert-equal "grant-denied" (dict-ref (control-for capture "err" 72) code: #f)) (enclave-stream-test-close! holder)))packages/slate-cli/test/test-git-branches.sgladded
(import (sigil test) (sigil fs) (sigil path) (sigil string) (sigil system grant) (sigil system process) (slate-cli git status) (slate-cli git branch))(define ROOT #f)(define GRANTS #f)(define REPO #f)(define BASE #f)(define NEXT #f)(define (run . args) (process-exec GRANTS (append (list "git" "-C" ROOT) args)))(define (text . args) (string-trim (dict-ref (apply run args) stdout: "")))(define (empty!) (set! ROOT (make-temp-directory)) (set! GRANTS (make-grants principal: "branch-test")) (grant-add! GRANTS (string-append "fs:rw:" ROOT)) (grant-add! GRANTS "exec:full") (run "init" "-q" "--initial-branch=main") (run "config" "user.name" "Branch Test") (run "config" "user.email" "branch@example.invalid") (set! REPO (dict-ref (git-discover GRANTS #{ path: ROOT }) node-repo: #{})))(define (setup!) (empty!) (write-file-string (path-join ROOT "tracked.txt") "base\n") (run "add" "tracked.txt") (run "commit" "-q" "-m" "base") (set! BASE (text "rev-parse" "HEAD")) (run "branch" "old") (write-file-string (path-join ROOT "tracked.txt") "next\n") (run "commit" "-qam" "next") (set! NEXT (text "rev-parse" "HEAD")))(define (status) (git-status GRANTS #{ repo: REPO recent-limit: 0 }))(define (args name) (let ((s (status))) #{ repo: REPO name: name expected-snapshot: (dict-ref s snapshot: #{}) expected-branch: (dict-ref (dict-ref s branch: #{}) head: "") }))(define (create name oid switch?) (git-create-branch GRANTS (dict-set (dict-set (args name) start-oid: oid) switch: switch?)))(define (switch name) (git-switch-branch GRANTS (dict-set (args name) target-oid: (text "rev-parse" (string-append "refs/heads/" name)))))(define (fails thunk) (guard (e (#t #t)) (thunk) #f))(test "unborn repositories can create-and-switch their initial branch" (empty!) (assert-equal '() (dict-ref (git-branches GRANTS #{ repo: REPO }) branches: #f)) (assert-true (dict-ref (create "initial" "" #t) applied: #f)) (assert-equal "initial" (text "symbolic-ref" "--short" "HEAD")))(test "local refs preserve names and exact object IDs" (setup!) (run "branch" "feature/naïve" BASE) (let* ((reply (git-branches GRANTS #{ repo: REPO })) (branch (find (lambda (b) (equal? "feature/naïve" (dict-ref b name: #f))) (dict-ref reply branches: '())))) (assert-equal BASE (dict-ref branch oid: #f)) (assert-equal "main" (dict-ref (dict-ref reply branch: #{}) head: #f))))(test "create from selected commit leaves HEAD and index untouched" (setup!) (let ((index (read-file-bytes (path-join ROOT ".git/index")))) (assert-true (dict-ref (create "review/old" BASE #f) applied: #f)) (assert-equal BASE (text "rev-parse" "review/old")) (assert-equal NEXT (text "rev-parse" "HEAD")) (assert-equal index (read-file-bytes (path-join ROOT ".git/index")))))(test "create-and-switch and switch return authoritative status" (setup!) (let ((reply (create "new/topic" NEXT #t))) (assert-equal "new/topic" (dict-ref (dict-ref (dict-ref reply status: #{}) branch: #{}) head: #f))) (assert-true (dict-ref (switch "main") applied: #f)) (assert-equal "main" (text "symbolic-ref" "--short" "HEAD")))(test "switch preserves non-conflicting local work" (setup!) (run "branch" "same-tip") (write-file-string (path-join ROOT "untracked.txt") "keep me\n") (write-file-string (path-join ROOT "tracked.txt") "local changes\n") (switch "same-tip") (assert-equal "same-tip" (text "symbolic-ref" "--short" "HEAD")) (assert-equal "local changes\n" (read-file-string (path-join ROOT "tracked.txt"))) (assert-equal "keep me\n" (read-file-string (path-join ROOT "untracked.txt"))))(test "conflicting checkout preserves files, index and refs" (setup!) (write-file-string (path-join ROOT "tracked.txt") "local changes\n") (let ((index (read-file-bytes (path-join ROOT ".git/index")))) (assert-true (fails (lambda () (switch "old")))) (assert-true (fails (lambda () (create "refused" BASE #t)))) (assert-equal "main" (text "symbolic-ref" "--short" "HEAD")) (assert-equal "local changes\n" (read-file-string (path-join ROOT "tracked.txt"))) (assert-equal index (read-file-bytes (path-join ROOT ".git/index"))) (assert-false (= 0 (dict-ref (run "show-ref" "--verify" "refs/heads/refused") exit: -1)))))(test "existing refs and invalid names cannot be overwritten" (setup!) (assert-true (fails (lambda () (create "old" NEXT #f)))) (assert-equal BASE (text "rev-parse" "old")) (for-each (lambda (name) (assert-true (fails (lambda () (create name BASE #f))))) '("--force" "HEAD" "../escape" "bad..name" "@{-1}" "has space" "refs.lock")) (assert-true (fails (lambda () (create "invalid-start" "HEAD~1" #f)))))(test "stale snapshots reject branch changes" (setup!) (let ((request (dict-set (args "old") target-oid: BASE))) (write-file-string (path-join ROOT "untracked.txt") "changed after prompt\n") (assert-true (fails (lambda () (git-switch-branch GRANTS request)))) (assert-equal "main" (text "symbolic-ref" "--short" "HEAD"))))(test "publication rechecks the selected branch tip" (setup!) (let ((request (dict-set (args "old") target-oid: BASE))) (git-branch-before-publish-hook! (lambda () (run "branch" "-f" "old" NEXT))) (assert-true (fails (lambda () (git-switch-branch GRANTS request)))) (git-branch-before-publish-hook! #f) (assert-equal "main" (text "symbolic-ref" "--short" "HEAD"))))(test "publication rechecks repository state before creating a ref" (setup!) (let ((request (dict-set (dict-set (args "raced") start-oid: BASE) switch: #f))) (git-branch-before-publish-hook! (lambda () (write-file-string (path-join ROOT "new.txt") "race\n"))) (assert-true (fails (lambda () (git-create-branch GRANTS request)))) (git-branch-before-publish-hook! #f) (assert-false (= 0 (dict-ref (run "show-ref" "--verify" "refs/heads/raced") exit: -1)))))(test "read-only grants and canonical mismatch cannot change branches" (setup!) (let ((readonly (make-grants principal: "reader"))) (grant-add! readonly (string-append "fs:ro:" ROOT)) (grant-add! readonly "exec:full") (assert-true (dict? (git-branches readonly #{ repo: REPO }))) (assert-true (fails (lambda () (git-create-branch readonly (dict-set (dict-set (args "denied") start-oid: BASE) switch: #f)))))) (assert-true (fails (lambda () (git-branches GRANTS #{ repo: (dict-set REPO common-dir: ROOT) })))))(test "linked worktree ownership is respected" (setup!) (let ((linked (path-join ROOT "linked"))) (run "worktree" "add" "-q" linked "old") (assert-true (fails (lambda () (switch "old")))) (assert-equal "main" (text "symbolic-ref" "--short" "HEAD"))))(test "in-progress operations refuse branch mutations" (setup!) (write-file-string (path-join ROOT ".git/CHERRY_PICK_HEAD") (string-append BASE "\n")) (assert-true (fails (lambda () (create "during-pick" BASE #f)))))(test "branch listing refuses overflow rather than silently omitting refs" (setup!) (let loop ((i 0)) (when (< i 499) (run "branch" (string-append "many/" (number->string i)) BASE) (loop (+ i 1)))) (assert-true (fails (lambda () (git-branches GRANTS #{ repo: REPO })))))(test-summary)packages/slate-cli/test/test-git-diff-contract.sgladded
;;; A4 node-owned structured diff paging.(import (sigil core) (sigil test) (sigil fs) (sigil path) (sigil process) (sigil async) (sigil system grant) (sigil system process) (slate-cli git status) (slate-cli git diff))(define ROOT (make-temp-directory))(define REPO (path-join ROOT "repo"))(ensure-directory REPO)(define GRANTS (make-grants principal: "git-diff-test"))(grant-add! GRANTS (string-append "fs:rw:" ROOT))(grant-add! GRANTS "exec:full")(define (run . args) (process-exec GRANTS (append (list "git" "-C" REPO) args)))(run "init" "-q" "--initial-branch=main")(run "config" "user.name" "Slate Test")(run "config" "user.email" "slate@example.invalid")(write-file-string (path-join REPO "review.txt") "one\ntwo\nthree\n")(write-file-string (path-join REPO "huge.txt") "base\n")(run "add" "--" "review.txt" "huge.txt")(run "commit" "-q" "-m" "base")(write-file-string (path-join REPO "review.txt") "one\nchanged\nthree\nadded\n")(define DISCOVERY (git-discover GRANTS #{ path: REPO }))(define NODE-REPO (dict-ref DISCOVERY node-repo: #{}))(define REPOSITORY #{ root: "test" worktree: (dict-ref NODE-REPO worktree: "") common-dir: (dict-ref NODE-REPO common-dir: "") })(define STATUS (git-status GRANTS #{ repo: REPOSITORY recent-limit: 0 }))(define SNAPSHOT (dict-ref STATUS snapshot: #{}))(test "worktree diff returns structured bounded pages" (let* ((page (git-diff GRANTS #{ repo: REPOSITORY snapshot: SNAPSHOT kind: "worktree" target: "review.txt" cursor: #f max-lines: 3 max-bytes: 262144 })) (file (car (dict-ref page files: '()))) (lines (dict-ref file hunks: '()))) (assert-equal 1 (dict-ref page version: 0)) (assert-true (<= (length lines) 3)) (assert-false (dict-ref page complete: #t)) (assert-true (string? (dict-ref page next-cursor: #f))) (git-diff GRANTS #{ repo: REPOSITORY snapshot: SNAPSHOT kind: "worktree" target: "review.txt" cursor: (dict-ref page next-cursor: #f) cancel: #t max-lines: 3 max-bytes: 262144 })))(test "continuation completes without exceeding requested line ceiling" (let loop ((cursor #f) (pages 0)) (let ((page (git-diff GRANTS #{ repo: REPOSITORY snapshot: SNAPSHOT kind: "worktree" target: "review.txt" cursor: cursor max-lines: 4 max-bytes: 262144 }))) (assert-true (<= (length (dict-ref (car (dict-ref page files: '())) hunks: '())) 4)) (if (dict-ref page complete: #f) (assert-true (> pages 0)) (loop (dict-ref page next-cursor: #f) (+ pages 1))))))(test "hunk lines carry old and new source coordinates" (let* ((records (git-diff-records "@@ -10,2 +20,2 @@\n old\n-gone\n+new")) (context (list-ref records 1)) (deleted (list-ref records 2)) (added (list-ref records 3))) (assert-equal 10 (dict-ref context old-line: #f)) (assert-equal 20 (dict-ref context new-line: #f)) (assert-equal 11 (dict-ref deleted old-line: #f)) (assert-equal 21 (dict-ref added new-line: #f))))(test "short completed diffs let asynchronous stderr drain after process exit" (let ((baseline (git-diff-active-stream-count))) (with-async (let loop ((remaining 8)) (when (> remaining 0) (let ((page (git-diff GRANTS #{ repo: REPOSITORY snapshot: SNAPSHOT kind: "worktree" target: "huge.txt" cursor: #f max-lines: 400 max-bytes: 262144 }))) (assert-true (dict-ref page complete: #f)) (assert-equal '() (dict-ref page files: '(unexpected)))) (loop (- remaining 1))))) (assert-equal baseline (git-diff-active-stream-count))))(test "untracked file diff compares the file against an empty source" (write-file-string (path-join REPO "new.txt") "first\nsecond\n") (let* ((fresh (git-status GRANTS #{ repo: REPOSITORY recent-limit: 0 })) (snap (dict-ref fresh snapshot: #{})) (page (git-diff GRANTS #{ repo: REPOSITORY snapshot: snap kind: "untracked" target: "new.txt" cursor: #f max-lines: 400 max-bytes: 262144 })) (records (dict-ref (car (dict-ref page files: '())) hunks: '()))) (assert-true (any (lambda (row) (eq? (dict-ref row kind: #f) addition:)) records))))(test "staged and commit diff kinds use the same structured contract" (run "add" "--" "review.txt") (let* ((fresh (git-status GRANTS #{ repo: REPOSITORY recent-limit: 0 })) (snap (dict-ref fresh snapshot: #{})) (staged (git-diff GRANTS #{ repo: REPOSITORY snapshot: snap kind: "staged" target: "review.txt" cursor: #f max-lines: 400 max-bytes: 262144 })) (head (dict-ref snap head-oid: "")) (commit (git-diff GRANTS #{ repo: REPOSITORY snapshot: snap kind: "commit" target: head cursor: #f max-lines: 400 max-bytes: 262144 }))) (assert-true (pair? (dict-ref staged files: '()))) (assert-true (pair? (dict-ref commit files: '())))))(test "byte boundaries preserve every multibyte character and continuation coordinate" (let* ((text (string-append (make-string 80 (string-ref "λ" 0)) "😀end")) (limit (+ 65536 37))) (write-file-string (path-join REPO "huge.txt") (string-append text "\n")) (let* ((fresh (git-status GRANTS #{ repo: REPOSITORY recent-limit: 0 })) (snap (dict-ref fresh snapshot: #{}))) (let loop ((cursor #f) (pages 0) (parts '())) (assert-true (< pages 30)) (let* ((page (git-diff GRANTS #{ repo: REPOSITORY snapshot: snap kind: "worktree" target: "huge.txt" cursor: cursor max-lines: 400 max-bytes: limit })) (files (dict-ref page files: '())) (records (if (null? files) '() (dict-ref (car files) hunks: '()))) (added (filter (lambda (row) (and (eq? (dict-ref row kind: #f) addition:) (> (dict-ref row new-line: 0) 0))) records)) (next (append parts (map (lambda (row) (dict-ref row text: "")) added)))) (assert-true (<= (+ (dict-ref page payload-bytes: limit) 256) limit)) (for-each (lambda (row) (assert-equal 1 (dict-ref row new-line: #f))) added) (if (dict-ref page complete: #f) (begin (assert-true (> pages 1)) (assert-equal (string-append "+" text) (apply string-append next))) (loop (dict-ref page next-cursor: #f) (+ pages 1) next)))))))(test "multi-megabyte single line streams in encoded-byte-bounded pages" (write-file-string (path-join REPO "huge.txt") (string-append (make-string 9000000 #\x) "\n")) (let* ((fresh (git-status GRANTS #{ repo: REPOSITORY recent-limit: 0 })) (snap (dict-ref fresh snapshot: #{}))) (let loop ((cursor #f) (pages 0)) (let* ((page (git-diff GRANTS #{ repo: REPOSITORY snapshot: snap kind: "worktree" target: "huge.txt" cursor: cursor max-lines: 400 max-bytes: 262144 })) (records (if (null? (dict-ref page files: '())) '() (dict-ref (car (dict-ref page files: '())) hunks: '())))) (assert-true (<= (+ (dict-ref page payload-bytes: 262145) 256) 262144)) (assert-true (<= (length records) 400)) (if (dict-ref page complete: #f) (assert-true (> pages 30)) (loop (dict-ref page next-cursor: #f) (+ pages 1)))))))(test "abandoned cursors are explicitly cancelled without leaking children" (let* ((fresh (git-status GRANTS #{ repo: REPOSITORY recent-limit: 0 })) (snap (dict-ref fresh snapshot: #{})) (baseline (git-diff-active-stream-count))) (let loop ((remaining 3) (tokens '())) (if (= remaining 0) (begin (assert-equal baseline (git-diff-active-stream-count)) (assert-equal 3 (length tokens))) (let* ((page (git-diff GRANTS #{ repo: REPOSITORY snapshot: snap kind: "worktree" target: "huge.txt" cursor: #f max-lines: 400 max-bytes: 262144 })) (cursor (dict-ref page next-cursor: #f))) (assert-true (string? cursor)) (assert-true (not (member cursor tokens))) (assert-equal (+ baseline 1) (git-diff-active-stream-count)) (git-diff GRANTS #{ repo: REPOSITORY snapshot: snap kind: "worktree" target: "huge.txt" cursor: cursor cancel: #t max-lines: 400 max-bytes: 262144 }) (assert-equal baseline (git-diff-active-stream-count)) (loop (- remaining 1) (cons cursor tokens)))))))(test "stale snapshot is rejected before diff execution" (write-file-string (path-join REPO "new-untracked.txt") "new generation\n") (guard (e (#t (assert-true #t))) (git-diff GRANTS #{ repo: REPOSITORY snapshot: SNAPSHOT kind: "worktree" target: "review.txt" cursor: #f max-lines: 400 max-bytes: 262144 }) (assert-true #f)))(test "v1 line and byte ceilings fail closed" (guard (e (#t (assert-true #t))) (git-diff GRANTS #{ repo: REPOSITORY snapshot: SNAPSHOT kind: "worktree" target: "review.txt" cursor: #f max-lines: 401 max-bytes: 262144 }) (assert-true #f)))packages/slate-cli/test/test-git-discard.sgladded
(import (sigil core) (sigil test) (sigil fs) (sigil path) (sigil process) (sigil json) (sigil system grant) (sigil system process) (slate-cli wire) (slate-cli dispatch) (slate-cli config) (slate-cli git mutation))(define ROOT (make-temp-directory))(define ORIGIN "https://git-discard.test")(define GRANTS (make-grants principal: (string-append "web:" ORIGIN)))(grant-add! GRANTS (string-append "fs:rw:" ROOT))(grant-add! GRANTS "exec:full")(define DAEMON #{ config: (make-config state-dir: ROOT) instance: "discard-test" })(define REPO (path-join ROOT "repo"))(define (run . args) (process-exec GRANTS (append (list "git" "-C" REPO) args)))(define (req id verb args) (dispatch-req DAEMON ORIGIN GRANTS (decode-control (json-encode #{ t: "req" id: id verb: verb args: args }))))(define (ctl reply) (reply-control reply))(define (res reply) (dict-ref (ctl reply) result: #{}))(define (entry data path) (find (lambda (item) (string=? path (dict-ref item path: ""))) (dict-ref data entries: '())))(ensure-directory REPO)(run "init" "-q" "--initial-branch=main")(run "config" "user.name" "Slate Test")(run "config" "user.email" "slate@example.invalid")(write-file-string (path-join REPO "tracked.txt") "base\n")(run "add" "--" "tracked.txt")(run "commit" "-q" "-m" "base")(define discovered (res (req 1 "git/discover" #{ path: REPO })))(define node-repo (dict-ref discovered node-repo: #{}))(define REPO-ID #{ root: "remote" worktree: (dict-ref node-repo worktree: "") common-dir: (dict-ref node-repo common-dir: "") })(define (status) (res (req 2 "git/status" #{ repo: REPO-ID recent-limit: 0 })))(define (discard id data path) (req id "git/discard-file" #{ repo: REPO-ID expected-snapshot: (dict-ref data snapshot: #{}) entry-id: (dict-ref (entry data path) id: "") }))(test "tracked file discard restores node-owned content" (write-file-string (path-join REPO "tracked.txt") "changed\n") (let ((reply (discard 10 (status) "tracked.txt"))) (assert-equal "ok" (dict-ref (ctl reply) t: "")) (assert-equal "base\n" (read-file-string (path-join REPO "tracked.txt")))))(test "untracked discard removes only the selected path" (write-file-string (path-join REPO "throw.txt") "throw\n") (write-file-string (path-join REPO "keep.txt") "keep\n") (let ((reply (discard 11 (status) "throw.txt"))) (assert-equal "ok" (dict-ref (ctl reply) t: "")) (assert-equal #f (file-exists? (path-join REPO "throw.txt"))) (assert-true (file-exists? (path-join REPO "keep.txt")))))(test "stale discard changes neither index nor newer worktree bytes" (write-file-string (path-join REPO "tracked.txt") "rendered\n") (let* ((old (status)) (wanted (entry old "tracked.txt"))) (write-file-string (path-join REPO "tracked.txt") "newer\n") (let ((reply (req 12 "git/discard-file" #{ repo: REPO-ID expected-snapshot: (dict-ref old snapshot: #{}) entry-id: (dict-ref wanted id: "") }))) (assert-equal "err" (dict-ref (ctl reply) t: "")) (assert-equal "stale-snapshot" (dict-ref (ctl reply) code: "")) (assert-equal "newer\n" (read-file-string (path-join REPO "tracked.txt"))))))packages/slate-cli/test/test-git-file-mutations.sgladded
(import (sigil core) (sigil test) (sigil fs) (sigil path) (sigil process) (sigil string) (sigil json) (sigil crypto) (sigil system grant) (sigil system process) (slate-cli wire) (slate-cli dispatch) (slate-cli config) (slate-cli git mutation))(define ROOT (make-temp-directory))(define ORIGIN "https://git-mutation.test")(define GRANTS (make-grants principal: (string-append "web:" ORIGIN)))(grant-add! GRANTS (string-append "fs:rw:" ROOT))(grant-add! GRANTS "exec:full")(define DAEMON #{ config: (make-config state-dir: ROOT) instance: "mutation-test" })(define (run cwd . args) (process-exec GRANTS (append (list "git" "-C" cwd) args)))(define (request id verb args) (dispatch-req DAEMON ORIGIN GRANTS (decode-control (json-encode #{ t: "req" id: id verb: verb args: args }))))(define (control reply) (reply-control reply))(define (result reply) (dict-ref (control reply) result: #{}))(define (repo-for path root-name) (let ((node-repo (dict-ref (result (request 1 "git/discover" #{ path: path })) node-repo: #{}))) #{ root: root-name worktree: (dict-ref node-repo worktree: "") common-dir: (dict-ref node-repo common-dir: "") }))(define (status repo) (result (request 2 "git/status" #{ repo: repo recent-limit: 0 })))(define (entry-for data path) (find (lambda (entry) (string=? (dict-ref entry path: "") path)) (dict-ref data entries: '())))(define (mutate id verb repo snapshot entry) (request id verb #{ repo: repo expected-snapshot: snapshot entry-id: (dict-ref entry id: "") }))(define (git-text cwd . args) (string-trim (dict-ref (apply run cwd args) stdout: "")))(define (index-bytes repo) (read-file-bytes (path-join (dict-ref repo common-dir: "") "index")))(define REPO (path-join ROOT "repo"))(ensure-directory REPO)(run REPO "init" "-q" "--initial-branch=main")(run REPO "config" "user.name" "Slate Test")(run REPO "config" "user.email" "slate@example.invalid")(write-file-string (path-join REPO "both.txt") "base\n")(write-file-string (path-join REPO "delete.txt") "delete\n")(write-file-string (path-join REPO "old name.txt") "rename\n")(run REPO "add" "--" "both.txt" "delete.txt" "old name.txt")(run REPO "commit" "-q" "-m" "base")(test-group "snapshot guarded file mutations" (test "linked worktree unstages a newly added file from its own index" (let ((linked (path-join ROOT "linked"))) (run REPO "worktree" "add" "-q" "-b" "linked-file-test" linked) (write-file-string (path-join linked "linked-new.txt") "new\n") (let* ((repo (repo-for linked "linked")) (before (status repo)) (staged (mutate 8 "git/stage-file" repo (dict-ref before snapshot: #{}) (entry-for before "linked-new.txt"))) (after (result staged)) (unstaged (mutate 9 "git/unstage-file" repo (dict-ref after snapshot: #{}) (entry-for after "linked-new.txt")))) (assert-equal "ok" (dict-ref (control staged) t: "")) (assert-equal "ok" (dict-ref (control unstaged) t: "")) (assert-equal "" (git-text linked "diff" "--cached" "--" "linked-new.txt"))))) (test "stage and unstage hostile untracked filenames through real dispatch" (for-each (lambda (path) (write-file-string (path-join REPO path) "new\n") (let* ((repo (repo-for REPO "local")) (before (status repo)) (entry (entry-for before path)) (staged (mutate 10 "git/stage-file" repo (dict-ref before snapshot: #{}) entry))) (assert-equal "ok" (dict-ref (control staged) t: "")) (let* ((after (result staged)) (staged-entry (entry-for after path)) (unstaged (mutate 11 "git/unstage-file" repo (dict-ref after snapshot: #{}) staged-entry))) (assert-equal "ok" (dict-ref (control unstaged) t: "")) (assert-true (file-exists? (path-join REPO path)))))) (list "space name.txt" "tab\tname.txt" "line\nname.txt" "naïve.txt" "-leading.txt"))) (test "staged plus unstaged state stages only the selected current state" (write-file-string (path-join REPO "both.txt") "staged\n") (run REPO "add" "--" "both.txt") (write-file-string (path-join REPO "both.txt") "worktree\n") (let* ((repo (repo-for REPO "local")) (before (status repo)) (reply (mutate 20 "git/stage-file" repo (dict-ref before snapshot: #{}) (entry-for before "both.txt")))) (assert-equal "ok" (dict-ref (control reply) t: "")) (assert-equal "worktree" (git-text REPO "show" ":both.txt")))) (test "deleted and renamed entries preserve Git path semantics" (run REPO "reset" "-q" "--hard" "HEAD") (delete-file (path-join REPO "delete.txt")) (run REPO "mv" "--" "old name.txt" "new name.txt") (let* ((repo (repo-for REPO "local")) (before (status repo)) (deleted (entry-for before "delete.txt")) (r1 (mutate 30 "git/stage-file" repo (dict-ref before snapshot: #{}) deleted)) (after1 (result r1)) (renamed (entry-for after1 "new name.txt")) (r2 (mutate 31 "git/unstage-file" repo (dict-ref after1 snapshot: #{}) renamed))) (assert-equal "ok" (dict-ref (control r1) t: "")) (assert-equal "ok" (dict-ref (control r2) t: "")) (let* ((after2 (result r2)) (renamed2 (or (entry-for after2 "new name.txt") (entry-for after2 "old name.txt"))) (r3 (mutate 32 "git/stage-file" repo (dict-ref after2 snapshot: #{}) renamed2))) (assert-equal "ok" (dict-ref (control r3) t: ""))))) (test "stale snapshot changes neither index nor newer worktree state" (run REPO "reset" "-q" "--hard" "HEAD") (write-file-string (path-join REPO "both.txt") "rendered\n") (let* ((repo (repo-for REPO "remote")) (rendered (status repo)) (entry (entry-for rendered "both.txt"))) (write-file-string (path-join REPO "both.txt") "concurrent\n") (let* ((current (status repo)) (index-before (index-bytes repo)) (worktree-before (read-file-string (path-join REPO "both.txt"))) (reply (mutate 40 "git/stage-file" repo (dict-ref rendered snapshot: #{}) entry)) (after (status repo))) (assert-equal "err" (dict-ref (control reply) t: "")) (assert-equal "stale-snapshot" (dict-ref (control reply) code: "")) (assert-equal index-before (index-bytes repo)) (assert-equal worktree-before (read-file-string (path-join REPO "both.txt")))))) (test "edit in the validation publication window changes neither index nor worktree" (run REPO "reset" "-q" "--hard" "HEAD") (write-file-string (path-join REPO "both.txt") "rendered-window\n") (let* ((repo (repo-for REPO "remote")) (rendered (status repo)) (entry (entry-for rendered "both.txt")) (index-before (index-bytes repo))) (git-mutation-before-publish-hook! (lambda () (git-mutation-before-publish-hook! #f) (write-file-string (path-join REPO "both.txt") "exact-window\n"))) (let ((reply (mutate 45 "git/stage-file" repo (dict-ref rendered snapshot: #{}) entry))) (git-mutation-before-publish-hook! #f) (assert-equal "err" (dict-ref (control reply) t: "")) (assert-equal "stale-snapshot" (dict-ref (control reply) code: "")) (assert-equal index-before (index-bytes repo)) (assert-equal "exact-window\n" (read-file-string (path-join REPO "both.txt")))))) (test "atomic publication failure leaves index and worktree byte-identical" (run REPO "reset" "-q" "--hard" "HEAD") (write-file-string (path-join REPO "both.txt") "publish-failure\n") (let* ((repo (repo-for REPO "remote")) (rendered (status repo)) (entry (entry-for rendered "both.txt")) (lock (path-join (dict-ref repo common-dir: "") "index.lock")) (index-before (index-bytes repo)) (worktree-before (read-file-string (path-join REPO "both.txt")))) (git-mutation-before-publish-hook! (lambda () (git-mutation-before-publish-hook! #f) (write-file-string lock "held\n"))) (let ((reply (mutate 46 "git/stage-file" repo (dict-ref rendered snapshot: #{}) entry))) (git-mutation-before-publish-hook! #f) (when (file-exists? lock) (delete-file lock)) (assert-equal "err" (dict-ref (control reply) t: "")) (assert-equal "operation-failed" (dict-ref (control reply) code: "")) (assert-equal index-before (index-bytes repo)) (assert-equal worktree-before (read-file-string (path-join REPO "both.txt")))))) (test "unborn HEAD unstage preserves the worktree file" (let ((unborn (path-join ROOT "unborn"))) (ensure-directory unborn) (run unborn "init" "-q" "--initial-branch=main") (write-file-string (path-join unborn "intent.txt") "intent\n") (run unborn "add" "-N" "--" "intent.txt") (let* ((repo (repo-for unborn "local")) (before (status repo)) (entry (entry-for before "intent.txt")) (staged (mutate 50 "git/stage-file" repo (dict-ref before snapshot: #{}) entry)) (after (result staged)) (unstaged (mutate 51 "git/unstage-file" repo (dict-ref after snapshot: #{}) (entry-for after "intent.txt")))) (assert-equal "ok" (dict-ref (control staged) t: "")) (assert-equal "ok" (dict-ref (control unstaged) t: "")) (assert-true (file-exists? (path-join unborn "intent.txt")))))))packages/slate-cli/test/test-git-hunk-mutations.sgladded
(import (sigil core) (sigil test) (sigil fs) (sigil path) (sigil process) (sigil string) (sigil json) (sigil crypto) (sigil system grant) (sigil system process) (slate-cli wire) (slate-cli dispatch) (slate-cli config) (slate-cli git mutation))(define ROOT (make-temp-directory))(define ORIGIN "https://git-hunk-mutation.test")(define GRANTS (make-grants principal: (string-append "web:" ORIGIN)))(grant-add! GRANTS (string-append "fs:rw:" ROOT))(grant-add! GRANTS "exec:full")(define DAEMON #{ config: (make-config state-dir: ROOT) instance: "hunk-test" })(define (run cwd . args) (process-exec GRANTS (append (list "git" "-C" cwd) args)))(define (request id verb args) (dispatch-req DAEMON ORIGIN GRANTS (decode-control (json-encode #{ t: "req" id: id verb: verb args: args }))))(define (control reply) (reply-control reply))(define (result reply) (dict-ref (control reply) result: #{}))(define (repo-for path) (let ((node-repo (dict-ref (result (request 1 "git/discover" #{ path: path })) node-repo: #{}))) #{ root: "remote" worktree: (dict-ref node-repo worktree: "") common-dir: (dict-ref node-repo common-dir: "") }))(define (status repo) (result (request 2 "git/status" #{ repo: repo recent-limit: 0 })))(define (entry-for data path) (find (lambda (entry) (string=? (dict-ref entry path: "") path)) (dict-ref data entries: '())))(define (git-text cwd . args) (dict-ref (apply run cwd args) stdout: ""))(define (index-bytes repo) (read-file-bytes (path-join (dict-ref repo common-dir: "") "index")))(define (range-parts token) (let* ((parts (string-split (substring token 1 (string-length token)) ","))) (cons (or (string->number (car parts)) 0) (if (null? (cdr parts)) 1 (or (string->number (cadr parts)) 0)))))(define (identities data path side diff) (let ((file-id (dict-ref (entry-for data path) id: ""))) (filter-map (lambda (line) (and (string-starts-with? line "@@ ") (let* ((parts (string-split line " ")) (old (range-parts (list-ref parts 1))) (new (range-parts (list-ref parts 2)))) #{ file-id: file-id side: side old-start: (car old) old-lines: (cdr old) new-start: (car new) new-lines: (cdr new) context-hash: (sha256 line) }))) (string-split diff "\n"))))(define (mutate id verb repo snapshot identity) (request id verb #{ repo: repo expected-snapshot: snapshot hunk-id: identity }))(define (make-lines first second) (string-join (map (lambda (n) (cond ((= n 3) first) ((= n 70) second) (else (string-append "line " (number->string n))))) (iota 100)) "\n"))(define REPO (path-join ROOT "repo"))(ensure-directory REPO)(run REPO "init" "-q" "--initial-branch=main")(run REPO "config" "user.name" "Slate Test")(run REPO "config" "user.email" "slate@example.invalid")(write-file-string (path-join REPO "multi.txt") (make-lines "line 3" "line 70"))(run REPO "add" "--" "multi.txt")(run REPO "commit" "-q" "-m" "base")(test-group "node-owned hunk mutations" (test "linked worktree stages a hunk against its own index" (let ((linked (path-join ROOT "linked"))) (run REPO "worktree" "add" "-q" "-b" "linked-hunk-test" linked) (write-file-string (path-join linked "multi.txt") (make-lines "linked change" "line 70")) (let* ((repo (repo-for linked)) (before (status repo)) (wanted (car (identities before "multi.txt" "worktree" (git-text linked "diff" "--" "multi.txt")))) (staged (mutate 8 "git/stage-hunk" repo (dict-ref before snapshot: #{}) wanted))) (assert-equal "ok" (dict-ref (control staged) t: "")) (assert-true (string-contains? (git-text linked "diff" "--cached") "linked change"))))) (test "stage the exact non-first worktree hunk and unstage its exact index hunk" (write-file-string (path-join REPO "multi.txt") (make-lines "changed first" "changed second")) (let* ((repo (repo-for REPO)) (before (status repo)) (ids (identities before "multi.txt" "worktree" (git-text REPO "diff" "--" "multi.txt"))) (staged (mutate 10 "git/stage-hunk" repo (dict-ref before snapshot: #{}) (cadr ids)))) (assert-equal "ok" (dict-ref (control staged) t: "")) (assert-equal (make-lines "line 3" "changed second") (git-text REPO "show" ":multi.txt")) (let* ((after (result staged)) (staged-ids (identities after "multi.txt" "staged" (git-text REPO "diff" "--cached" "--" "multi.txt"))) (unstaged (mutate 11 "git/unstage-hunk" repo (dict-ref after snapshot: #{}) (car staged-ids)))) (assert-equal "ok" (dict-ref (control unstaged) t: "")) (assert-equal (make-lines "line 3" "line 70") (git-text REPO "show" ":multi.txt")) (assert-equal "" (git-text REPO "diff" "--cached"))))) (test "new first hunk returns stale and changes nothing" (run REPO "reset" "-q" "--hard" "HEAD") (write-file-string (path-join REPO "multi.txt") (make-lines "rendered" "line 70")) (let* ((repo (repo-for REPO)) (rendered (status repo)) (wanted (car (identities rendered "multi.txt" "worktree" (git-text REPO "diff" "--" "multi.txt"))))) (write-file-string (path-join REPO "multi.txt") (make-lines "rendered" "inserted new hunk")) (let ((index-before (index-bytes repo)) (worktree-before (read-file-string (path-join REPO "multi.txt"))) (reply (mutate 20 "git/stage-hunk" repo (dict-ref rendered snapshot: #{}) wanted))) (assert-equal "err" (dict-ref (control reply) t: "")) (assert-equal "stale-snapshot" (dict-ref (control reply) code: "")) (assert-equal index-before (index-bytes repo)) (assert-equal worktree-before (read-file-string (path-join REPO "multi.txt")))))) (test "no-newline marker is preserved by partial staging" (run REPO "reset" "-q" "--hard" "HEAD") (write-file-string (path-join REPO "multi.txt") "replacement without newline") (let* ((repo (repo-for REPO)) (before (status repo)) (wanted (car (identities before "multi.txt" "worktree" (git-text REPO "diff" "--" "multi.txt")))) (reply (mutate 30 "git/stage-hunk" repo (dict-ref before snapshot: #{}) wanted))) (assert-equal "ok" (dict-ref (control reply) t: "")) (assert-true (string-contains? (git-text REPO "diff" "--cached") "No newline at end of file")))) (test "publication failure leaves index and worktree byte-identical" (run REPO "reset" "-q" "--hard" "HEAD") (write-file-string (path-join REPO "multi.txt") (make-lines "lock failure" "line 70")) (let* ((repo (repo-for REPO)) (before (status repo)) (wanted (car (identities before "multi.txt" "worktree" (git-text REPO "diff" "--" "multi.txt")))) (lock (path-join (dict-ref repo common-dir: "") "index.lock")) (index-before (index-bytes repo)) (worktree-before (read-file-string (path-join REPO "multi.txt")))) (git-mutation-before-publish-hook! (lambda () (git-mutation-before-publish-hook! #f) (write-file-string lock "held\n"))) (let ((reply (mutate 35 "git/stage-hunk" repo (dict-ref before snapshot: #{}) wanted))) (git-mutation-before-publish-hook! #f) (when (file-exists? lock) (delete-file lock)) (assert-equal "err" (dict-ref (control reply) t: "")) (assert-equal "operation-failed" (dict-ref (control reply) code: "")) (assert-equal index-before (index-bytes repo)) (assert-equal worktree-before (read-file-string (path-join REPO "multi.txt")))))) (test "binary partial staging is rejected without mutation" (run REPO "reset" "-q" "--hard" "HEAD") (write-file-bytes (path-join REPO "binary.bin") (bytevector 0 1 2 3)) (run REPO "add" "--" "binary.bin") (run REPO "commit" "-q" "-m" "binary") (write-file-bytes (path-join REPO "binary.bin") (bytevector 0 8 2 3)) (let* ((repo (repo-for REPO)) (before (status repo)) (entry (entry-for before "binary.bin")) (fake #{ file-id: (dict-ref entry id: "") side: "worktree" old-start: 1 old-lines: 1 new-start: 1 new-lines: 1 context-hash: "fake" }) (index-before (index-bytes repo)) (reply (mutate 40 "git/stage-hunk" repo (dict-ref before snapshot: #{}) fake))) (assert-equal "err" (dict-ref (control reply) t: "")) (assert-equal "operation-failed" (dict-ref (control reply) code: "")) (assert-equal index-before (index-bytes repo)))) (test "rename and mode-only partial staging are rejected" (run REPO "reset" "-q" "--hard" "HEAD") (run REPO "mv" "--" "multi.txt" "renamed.txt") (let* ((repo (repo-for REPO)) (before (status repo)) (entry (entry-for before "renamed.txt")) (fake #{ file-id: (dict-ref entry id: "") side: "staged" old-start: 1 old-lines: 1 new-start: 1 new-lines: 1 context-hash: "fake" }) (index-before (index-bytes repo)) (reply (mutate 50 "git/unstage-hunk" repo (dict-ref before snapshot: #{}) fake))) (assert-equal "err" (dict-ref (control reply) t: "")) (assert-equal "operation-failed" (dict-ref (control reply) code: "")) (assert-equal index-before (index-bytes repo))) (run REPO "reset" "-q" "--hard" "HEAD") (run REPO "update-index" "--chmod=+x" "multi.txt") (let* ((repo (repo-for REPO)) (before (status repo)) (entry (entry-for before "multi.txt")) (fake #{ file-id: (dict-ref entry id: "") side: "staged" old-start: 1 old-lines: 1 new-start: 1 new-lines: 1 context-hash: "fake" }) (index-before (index-bytes repo)) (reply (mutate 51 "git/unstage-hunk" repo (dict-ref before snapshot: #{}) fake))) (assert-equal "err" (dict-ref (control reply) t: "")) (assert-equal "operation-failed" (dict-ref (control reply) code: "")) (assert-equal index-before (index-bytes repo)))))packages/slate-cli/test/test-git-log.sgladded
(import (sigil test) (sigil fs) (sigil path) (sigil string) (sigil system grant) (sigil system process) (slate-cli git status) (slate-cli git log) (slate-cli git diff))(define ROOT (make-temp-directory))(define GRANTS (make-grants principal: "history-test"))(grant-add! GRANTS (string-append "fs:rw:" ROOT))(grant-add! GRANTS "exec:full")(define (run . args) (process-exec GRANTS (append (list "git" "-C" ROOT) args)))(run "init" "-q" "--initial-branch=main")(run "config" "user.name" "History Test")(run "config" "user.email" "history@example.invalid")(define REPO (dict-ref (git-discover GRANTS #{ path: ROOT }) node-repo: #{}))(define UNBORN (git-log GRANTS #{ repo: REPO }))(test "unborn history is empty" (assert-equal '() (dict-ref UNBORN commits: #f)))(write-file-string (path-join ROOT "first.txt") "root commit text\n")(run "add" "--" "first.txt")(run "commit" "-q" "-m" "Initial naïve subject" "-m" "A body with <markup> and a second paragraph.")(define FIRST (dict-ref (car (dict-ref (git-log GRANTS #{ repo: REPO }) commits: '())) oid: ""))(run "checkout" "-q" "-b" "topic")(write-file-string (path-join ROOT "topic.txt") "topic change\n")(run "add" "--" "topic.txt")(run "commit" "-q" "-m" "Topic change")(run "checkout" "-q" "main")(run "commit" "-q" "--allow-empty" "-m" "Main change")(run "merge" "-q" "--no-ff" "topic" "-m" "Merge topic")(define PAGE (git-log GRANTS #{ repo: REPO limit: 2 }))(define MERGE (dict-ref (car (dict-ref PAGE commits: '())) oid: ""))(test "bounded history preserves merge relationships and refs" (assert-equal 2 (length (dict-ref PAGE commits: '()))) (assert-equal 2 (length (dict-ref (car (dict-ref PAGE commits: '())) parents: '()))) (assert-true (string-contains? (dict-ref (car (dict-ref PAGE commits: '())) refs: "") "main")) (assert-true (dict? (dict-ref PAGE next-cursor: #f))))(test "pages continue without duplicating the first page" (let ((next (git-log GRANTS #{ repo: REPO limit: 2 cursor: (dict-ref PAGE next-cursor: #f) }))) (assert-equal 2 (dict-ref next offset: -1)) (assert-equal #t (dict-ref next complete: #f)) (assert-equal FIRST (dict-ref (cadr (dict-ref next commits: '())) oid: #f))))(test "show returns full message and canonical identity without a patch" (let ((show (git-show GRANTS #{ repo: REPO oid: FIRST }))) (assert-equal REPO (dict-ref show repo: #f)) (assert-true (string-contains? (dict-ref (dict-ref show commit: #{}) body: "") "<markup>")) (assert-equal #f (dict-ref show files: #f))))(define (diff-text oid) (let* ((snapshot (dict-ref (git-status GRANTS #{ repo: REPO recent-limit: 0 }) snapshot: #{})) (page (git-diff GRANTS #{ repo: REPO snapshot: snapshot kind: "commit" target: oid max-lines: 400 max-bytes: 262144 }))) (string-join (fold-left (lambda (out file) (append out (map (lambda (row) (dict-ref row text: "")) (dict-ref file hunks: '())))) '() (dict-ref page files: '())) "\n")))(test "root commit inspection includes the initial tree" (assert-true (string-contains? (diff-text FIRST) "+root commit text")))(test "merge inspection compares the first parent" (assert-true (string-contains? (diff-text MERGE) "+topic change")))(define (fails thunk) (guard (e (#t #t)) (thunk) #f))(test "untrusted revision syntax and page bounds are refused" (assert-true (fails (lambda () (git-show GRANTS #{ repo: REPO oid: "--all" })))) (assert-true (fails (lambda () (git-log GRANTS #{ repo: REPO limit: 101 })))) (assert-true (fails (lambda () (git-log GRANTS #{ repo: REPO cursor: #{ offset: -1 } })))))(test "canonical repository mismatch and grant refusal fail" (assert-true (fails (lambda () (git-log GRANTS #{ repo: (dict-set REPO common-dir: ROOT) })))) (assert-true (fails (lambda () (git-log (make-grants principal: "denied") #{ repo: REPO })))))(test "a cursor cannot silently walk a changed HEAD" (run "commit" "-q" "--allow-empty" "-m" "New HEAD") (assert-true (fails (lambda () (git-log GRANTS #{ repo: REPO cursor: (dict-ref PAGE next-cursor: #f) })))))(test "oversized commit metadata cannot escape the page byte ceiling" (let ((message (path-join ROOT "oversized-message.txt"))) (write-file-string message (make-string 300000 #\x)) (run "commit" "-q" "--allow-empty" "-F" message) (let ((oid (dict-ref (dict-ref (git-status GRANTS #{ repo: REPO recent-limit: 0 }) snapshot: #{}) head-oid: #f))) (assert-true (fails (lambda () (git-show GRANTS #{ repo: REPO oid: oid })))) (assert-true (fails (lambda () (git-log GRANTS #{ repo: REPO })))))))packages/slate-cli/test/test-git-node-contract.sgladded
;;; A1 Git node contract: real Git repositories through the real unary dispatch.(import (sigil core) (sigil test) (sigil fs) (sigil path) (sigil json) (sigil process) (sigil io) (sigil string) (sigil log) (sigil system grant) (sigil system process) (slate-cli wire) (slate-cli dispatch) (slate-cli config) (slate-cli git exec) (slate-cli git status))(define ROOT (make-temp-directory))(define ORIGIN "https://git-contract.test")(define GRANTS (make-grants principal: (string-append "web:" ORIGIN)))(grant-add! GRANTS (string-append "fs:rw:" ROOT))(grant-add! GRANTS "exec:full")(define DAEMON #{ config: (make-config state-dir: ROOT) instance: "git-test" })(define (run cwd . args) (process-exec GRANTS (append (list "git" "-C" cwd) args)))(define REPO (path-join ROOT "repo"))(ensure-directory REPO)(run REPO "init" "-q" "--initial-branch=main")(run REPO "config" "user.name" "Slate Test")(run REPO "config" "user.email" "slate@example.invalid")(write-file-string (path-join REPO "tracked.txt") "base\n")(write-file-string (path-join REPO "old name.txt") "rename me\n")(write-file-bytes (path-join REPO "blob.bin") (bytevector 0 1 2 0 3))(run REPO "add" "--" "tracked.txt" "old name.txt" "blob.bin")(run REPO "commit" "-q" "-m" "initial")(write-file-string (path-join REPO "tracked.txt") "SECRET_PATCH_BODY_SHOULD_NOT_LOG\n")(write-file-bytes (path-join REPO "blob.bin") (bytevector 0 9 8 0 7))(run REPO "mv" "--" "old name.txt" "renamed -> file.txt")(for-each (lambda (name) (write-file-string (path-join REPO name) "special\n")) (list "space name.txt" "tab\tname.txt" "line\nname.txt" "naïve.txt" "-leading.txt" "from -> to.txt"))(define BARE (path-join ROOT "bare.git"))(ensure-directory BARE)(run BARE "init" "-q" "--bare")(define UNBORN (path-join ROOT "unborn"))(ensure-directory UNBORN)(run UNBORN "init" "-q" "--initial-branch=main")(define DIVERGED (path-join ROOT "diverged"))(ensure-directory DIVERGED)(run DIVERGED "init" "-q" "--initial-branch=main")(run DIVERGED "config" "user.name" "Slate Test")(run DIVERGED "config" "user.email" "slate@example.invalid")(write-file-string (path-join DIVERGED "base.txt") "base\n")(run DIVERGED "add" "--" "base.txt")(run DIVERGED "commit" "-q" "-m" "base")(run DIVERGED "branch" "upstream-base")(run DIVERGED "branch" "--set-upstream-to=upstream-base" "main")(write-file-string (path-join DIVERGED "ahead.txt") "ahead\n")(run DIVERGED "add" "--" "ahead.txt")(run DIVERGED "commit" "-q" "-m" "ahead")(run DIVERGED "checkout" "-q" "upstream-base")(write-file-string (path-join DIVERGED "behind.txt") "behind\n")(run DIVERGED "add" "--" "behind.txt")(run DIVERGED "commit" "-q" "-m" "behind")(run DIVERGED "checkout" "-q" "main")(define CONFLICT (path-join ROOT "conflict"))(ensure-directory CONFLICT)(run CONFLICT "init" "-q" "--initial-branch=main")(run CONFLICT "config" "user.name" "Slate Test")(run CONFLICT "config" "user.email" "slate@example.invalid")(write-file-string (path-join CONFLICT "both.txt") "base\n")(run CONFLICT "add" "--" "both.txt")(run CONFLICT "commit" "-q" "-m" "base")(run CONFLICT "checkout" "-q" "-b" "other")(write-file-string (path-join CONFLICT "both.txt") "other\n")(run CONFLICT "commit" "-q" "-am" "other")(run CONFLICT "checkout" "-q" "main")(write-file-string (path-join CONFLICT "both.txt") "main\n")(run CONFLICT "commit" "-q" "-am" "main")(guard (e (#t #f)) (run CONFLICT "merge" "other"))(define LINKED-CLEAN (path-join ROOT "linked-clean"))(define LINKED-CONFLICT (path-join ROOT "linked-conflict"))(run CONFLICT "worktree" "add" "-q" "-b" "linked-clean" LINKED-CLEAN "HEAD")(run CONFLICT "worktree" "add" "-q" "-b" "linked-conflict" LINKED-CONFLICT "HEAD")(guard (e (#t #f)) (run LINKED-CONFLICT "merge" "other"))(define (status-at path) (let* ((discovered (git-discover GRANTS #{ path: path })) (repo (dict-ref discovered node-repo: #{}))) (git-status GRANTS #{ repo: repo recent-limit: 0 })))(define (request id verb args grants) (dispatch-req DAEMON ORIGIN grants (decode-control (json-encode #{ t: "req" id: id verb: verb args: args }))))(define (control-result reply) (dict-ref (reply-control reply) result: #{}))(define (capture-debug thunk) (let ((sink (open-output-string))) (log-configure! target: sink level: 'debug format: 'text) (thunk) (let ((output (get-output-string sink))) (log-configure! level: 6) output)))(test-group "git node contract - real dispatch" (test "linked worktree merge is reported as active" (let* ((status (status-at LINKED-CONFLICT)) (operation (dict-ref status operation: #f))) (assert-true (file-exists? (path-join CONFLICT ".git/worktrees/linked-conflict/MERGE_HEAD"))) (assert-equal "merge" (if operation (dict-ref operation kind: "") "missing")))) (test "main checkout operation does not leak into a clean linked worktree" (assert-true (file-exists? (path-join CONFLICT ".git/MERGE_HEAD"))) (assert-false (dict-ref (status-at LINKED-CLEAN) operation: #f))) (test "captured output preserves UTF-8 across byte chunks" (let ((content (string-append (make-string 65535 #\a) "λ\x0;tail\n"))) (write-file-bytes (path-join LINKED-CLEAN "capture.txt") (string->utf8 content)) (run LINKED-CLEAN "add" "capture.txt") (let ((result (git-exec GRANTS LINKED-CLEAN (list "show" ":capture.txt")))) (assert-equal (string-length content) (string-length (dict-ref result stdout: ""))) (assert-true (string=? content (dict-ref result stdout: ""))) (assert-false (dict-ref result read-error?: #t))) (let ((result (git-exec GRANTS LINKED-CLEAN (list "show" ":capture.txt") output-limit: 65536))) (assert-true (dict-ref result output-exceeded?: #f)) (assert-false (dict-ref result read-error?: #t))))) (test "released runtime applies child cwd and environment" (let* ((child-dir (make-temp-directory)) (parent-dir (current-directory)) (variable "SLATE_GIT_CHILD_CONTEXT_PROBE") (program (string-append "(begin (import (sigil fs) (sigil process)) " "(display (current-directory)) (newline) " "(display (getenv \"" variable "\")) (newline))"))) (setenv! variable #f) (let* ((child (process-spawn (executable-path) "eval" program cwd: child-dir environment: (list (string-append variable "=child")))) (child-cwd (read-line (process-stdout child))) (child-value (read-line (process-stdout child)))) (assert-equal 0 (process-wait child)) (assert-equal child-dir child-cwd) (assert-equal "child" child-value) (assert-equal parent-dir (current-directory)) (assert-false (getenv variable))))) (test "status discovery stays bounded to two rev-parse processes" (let ((trace (path-join ROOT "status-trace.jsonl")) (repo (dict-ref (git-discover GRANTS #{ path: LINKED-CLEAN }) node-repo: #{}))) (let ((previous (getenv "GIT_TRACE2_EVENT"))) (dynamic-wind (lambda () (setenv! "GIT_TRACE2_EVENT" trace)) (lambda () (git-status GRANTS #{ repo: repo recent-limit: 0 })) (lambda () (setenv! "GIT_TRACE2_EVENT" previous)))) (let* ((lines (string-split (read-file-string trace) "\n")) (starts (filter (lambda (line) (string-contains? line "\"event\":\"start\"")) lines)) (discovery (filter (lambda (line) (string-contains? line "\"rev-parse\"")) starts))) (assert-equal 2 (length discovery)) (assert-true (<= (length starts) 5))))) (test "discover returns node-owned canonical facts" (let* ((reply (request 1 "git/discover" #{ path: REPO } GRANTS)) (control (reply-control reply)) (result (dict-ref control result: #{})) (node-repo (dict-ref result node-repo: #{}))) (assert-equal "ok" (dict-ref control t: "")) (assert-equal 1 (dict-ref result version: 0)) (assert-equal REPO (dict-ref node-repo worktree: "")) (assert-false (dict-contains? node-repo root:)))) (test "status independently validates and returns typed inventory" (let* ((discovered (control-result (request 2 "git/discover" #{ path: REPO } GRANTS))) (node-repo (dict-ref discovered node-repo: #{})) (repo #{ root: "local" worktree: (dict-ref node-repo worktree: "") common-dir: (dict-ref node-repo common-dir: "") }) (status (control-result (request 3 "git/status" #{ repo: repo recent-limit: 10 } GRANTS)))) (assert-equal repo (dict-ref status repo: #{})) (assert-true (pair? (dict-ref status entries: '()))) (assert-false (dict-contains? status patch:)) (assert-false (dict-contains? (car (dict-ref status entries: '())) patch:)))) (test "bare repository discovery returns canonical node identity" (let* ((result (control-result (request 31 "git/discover" #{ path: BARE } GRANTS))) (node-repo (dict-ref result node-repo: #{}))) (assert-true (dict-ref result bare: #f)) (assert-equal BARE (dict-ref node-repo worktree: "")) (assert-equal BARE (dict-ref node-repo common-dir: "")))) (test "status supports an unborn branch with no commit history" (let* ((discovered (control-result (request 36 "git/discover" #{ path: UNBORN } GRANTS))) (node-repo (dict-ref discovered node-repo: #{})) (repo #{ root: "local" worktree: (dict-ref node-repo worktree: "") common-dir: (dict-ref node-repo common-dir: "") }) (status (control-result (request 37 "git/status" #{ repo: repo recent-limit: 10 } GRANTS)))) (assert-true (dict-ref (dict-ref status branch: #{}) unborn: #f)) (assert-equal "" (dict-ref (dict-ref status snapshot: #{}) head-oid: "x")) (assert-equal '() (dict-ref status recent-commits: '(unexpected))))) (test "branch status reports both sides of upstream divergence" (let* ((discovered (control-result (request 32 "git/discover" #{ path: DIVERGED } GRANTS))) (node-repo (dict-ref discovered node-repo: #{})) (repo #{ root: "local" worktree: (dict-ref node-repo worktree: "") common-dir: (dict-ref node-repo common-dir: "") }) (status (control-result (request 33 "git/status" #{ repo: repo recent-limit: 10 } GRANTS))) (branch (dict-ref status branch: #{})) (commits (dict-ref status recent-commits: '()))) (assert-equal 1 (dict-ref branch ahead: 0)) (assert-equal 1 (dict-ref branch behind: 0)) (assert-true (pair? commits)) (assert-true (dict-contains? (car commits) subject:)))) (test "Git execution enforces its output ceiling" (let ((result (git-exec GRANTS ROOT (list "--version") output-limit: 1))) (assert-true (dict-ref result output-exceeded?: #f)))) (test "Git execution terminates a command at its deadline" (let ((result (git-exec GRANTS ROOT (list "hash-object" "--stdin") timeout: 0.01))) (assert-true (dict-ref result timeout?: #f)))) (test "missing Git is a named product error" (let* ((original-path (or (getenv "PATH") "")) (message (guard (e (#t (setenv! "PATH" original-path) (error-object-message e))) (setenv! "PATH" "") (git-exec GRANTS ROOT (list "--version")) (setenv! "PATH" original-path) "no-error"))) (assert-equal "git-unavailable" message))) (test "malformed status identity is a named operation failure" (let ((control (reply-control (request 38 "git/status" #{ repo: #{} recent-limit: 10 } GRANTS)))) (assert-equal "err" (dict-ref control t: "")) (assert-equal "operation-failed" (dict-ref control code: "")))) (test "status rejects a worktree and common-dir spliced across repositories" (let* ((left (control-result (request 41 "git/discover" #{ path: REPO } GRANTS))) (right (control-result (request 42 "git/discover" #{ path: DIVERGED } GRANTS))) (left-repo (dict-ref left node-repo: #{})) (right-repo (dict-ref right node-repo: #{})) (spliced #{ root: "local" worktree: (dict-ref left-repo worktree: "") common-dir: (dict-ref right-repo common-dir: "") }) (control (reply-control (request 43 "git/status" #{ repo: spliced recent-limit: 10 } GRANTS)))) (assert-equal "err" (dict-ref control t: "")) (assert-equal "operation-failed" (dict-ref control code: "")))) (test "dispatch logs correlated bounded outcomes without repository content" (let ((plain (path-join ROOT "log-plain"))) (ensure-directory plain) (let* ((discovered (control-result (request 44 "git/discover" #{ path: REPO } GRANTS))) (node-repo (dict-ref discovered node-repo: #{})) (repo #{ root: "local" worktree: (dict-ref node-repo worktree: "") common-dir: (dict-ref node-repo common-dir: "") }) (long-path (apply string-append (make-list 700 "x"))) (output (capture-debug (lambda () (request 45 "git/status" #{ repo: repo recent-limit: 1 } GRANTS) (request 46 "git/unsupported" #{ repo: repo } GRANTS) (request 47 "git/unsupported" #{ repo: #{ worktree: long-path } } GRANTS) (request 48 "git/discover" #{ path: plain } GRANTS))))) (assert-true (string-contains? output "node/rpc-begin")) (assert-true (string-contains? output "node/rpc-end")) (assert-true (string-contains? output "node/rpc-refused")) (assert-true (string-contains? output "req-id=45")) (assert-true (string-contains? output "req-id=46")) (assert-true (string-contains? output "verb=git/status")) (assert-true (string-contains? output "duration-ms=")) (assert-true (string-contains? output (string-append "path=" REPO))) (assert-true (string-contains? output "...")) (assert-false (string-contains? output long-path)) (assert-false (string-contains? output "SECRET_PATCH_BODY_SHOULD_NOT_LOG")) (assert-false (string-contains? output "fatal:"))))) (test "status reports an in-progress operation and conflict metadata" (let* ((discovered (control-result (request 34 "git/discover" #{ path: CONFLICT } GRANTS))) (node-repo (dict-ref discovered node-repo: #{})) (repo #{ root: "local" worktree: (dict-ref node-repo worktree: "") common-dir: (dict-ref node-repo common-dir: "") }) (status (control-result (request 35 "git/status" #{ repo: repo recent-limit: 10 } GRANTS))) (operation (dict-ref status operation: #{})) (conflicts (filter (lambda (entry) (string=? (dict-ref entry kind: "") "conflict")) (dict-ref status entries: '())))) (assert-equal "merge" (dict-ref operation kind: "")) (assert-true (pair? conflicts)) (assert-true (dict-contains? (car conflicts) conflict-stages:)))) (test "discovery outside the grant is refused without killing dispatch" (let* ((none (make-grants principal: "web:none")) (reply (request 4 "git/discover" #{ path: REPO } none)) (control (reply-control reply))) (assert-equal "err" (dict-ref control t: "")) (assert-equal "grant-denied" (dict-ref control code: "")))) (test "ordinary path is a named not-a-repository result" (let ((plain (path-join ROOT "plain"))) (ensure-directory plain) (let ((control (reply-control (request 5 "git/discover" #{ path: plain } GRANTS)))) (assert-equal "not-a-repository" (dict-ref control code: ""))))) (test "real dispatch preserves hostile filenames and rename pairing" (let* ((discovered (control-result (request 39 "git/discover" #{ path: REPO } GRANTS))) (node-repo (dict-ref discovered node-repo: #{})) (repo #{ root: "local" worktree: (dict-ref node-repo worktree: "") common-dir: (dict-ref node-repo common-dir: "") }) (status (control-result (request 40 "git/status" #{ repo: repo recent-limit: 10 } GRANTS))) (entries (dict-ref status entries: '())) (paths (map (lambda (entry) (dict-ref entry path: "")) entries)) (renames (filter (lambda (entry) (string=? (dict-ref entry kind: "") "rename")) entries))) (assert-true (member "space name.txt" paths)) (assert-true (member "tab\tname.txt" paths)) (assert-true (member "line\nname.txt" paths)) (assert-true (member "naïve.txt" paths)) (assert-true (member "-leading.txt" paths)) (assert-true (member "from -> to.txt" paths)) (assert-true (member "renamed -> file.txt" paths)) (let ((binary-entry (find (lambda (entry) (string=? (dict-ref entry path: "") "blob.bin")) entries))) (assert-true binary-entry) (assert-equal "unknown" (dict-ref binary-entry binary: #f))) (assert-true (pair? renames)) (assert-equal "old name.txt" (dict-ref (car renames) original-path: "")))))packages/slate-cli/test/test-http-stream.sglmodified
(sigil process) (sigil time) (sigil test) (slate-cli http stream)) (sigil http client))(test "incremental HTTP response exposes headers before split body chunks" (let* ((port (+ 19000 (modulo (current-jiffy) 1000)))packages/slate-cli/test/test-main-router.sgladded
;;; Regression for the app route accidentally being nested under /pair.(import (sigil test) (slate-cli main))(test-group "top-level HTTP routing" (test "the exact GET health endpoint is local" (assert-equal 'health (http-route-kind "GET" "/node/health"))) (test "the exact GET status endpoint is local" (assert-equal 'node-status (http-route-kind "GET" "/node/status"))) (test "the status endpoint is read-only" (assert-equal 'node-status-method (http-route-kind "POST" "/node/status"))) (test "performance captures post to the local node" (assert-equal 'node-performance (http-route-kind "POST" "/node/performance"))) (test "the performance endpoint refuses other methods" (assert-equal 'node-performance-method (http-route-kind "GET" "/node/performance"))) (test "pair paths reach the pairing handler" (assert-equal 'pair (http-route-kind "GET" "/pair/start"))) (test "the app root reaches the app proxy" (assert-equal 'app (http-route-kind "GET" "/"))) (test "ordinary assets reach the app proxy" (assert-equal 'app (http-route-kind "GET" "/assets/app.js"))))packages/slate-cli/test/test-node-dial.sgladded
(import (sigil test) (sigil core) (slate-cli node dial))(define clock 1000)(define (now-ms) (set! clock (+ clock 5)) clock)(define current-ad #{ endpoint: "ws://10.11.0.9:32644/direct" expires-at: 5000 ticket: "must-never-reach-relay" })(test-group "companion node direct dialing" (test "authenticated direct success does not mint a relay ticket" (let ((direct-calls 0) (relay-calls 0)) (let ((result (node-dial "zerocool" current-ad (lambda (target ad) (set! direct-calls (+ direct-calls 1)) #{ connection: 'direct-carrier }) (lambda (target) (set! relay-calls (+ relay-calls 1)) #{ connection: 'relay-carrier }) now-ms))) (assert-equal 'direct (dict-ref result transport: #f)) (assert-equal 'direct-carrier (dict-ref result connection: #f)) (assert-equal 1 direct-calls) (assert-equal 0 relay-calls)))) (test "unreachable direct endpoint falls back once with a fresh relay path" (let ((direct-calls 0) (relay-calls 0)) (let ((result (node-dial "phone" current-ad (lambda (target ad) (set! direct-calls (+ direct-calls 1)) #{ reason: 'timeout }) (lambda (target) (set! relay-calls (+ relay-calls 1)) ;; The callback receives no advertisement or direct ticket. #{ connection: 'fresh-relay-carrier }) now-ms))) (assert-equal 'relay (dict-ref result transport: #f)) (assert-equal 'timeout (dict-ref result fallback-reason: #f)) (assert-equal 1 direct-calls) (assert-equal 1 relay-calls)))) (test "expired advertisement is never dialled and uses relay" (let ((direct-calls 0)) (let ((result (node-dial "sleeping-phone" #{ endpoint: "ws://10.11.0.8:32644/direct" expires-at: 1 } (lambda (target ad) (set! direct-calls (+ direct-calls 1)) #{ connection: 'should-not-connect }) (lambda (target) #{ connection: 'relay-carrier }) now-ms))) (assert-equal 0 direct-calls) (assert-equal 'relay (dict-ref result transport: #f)) (assert-equal 'stale-advertisement (dict-ref result fallback-reason: #f))))) (test "peer authentication failure is observable but non-secret" (let ((result (node-dial "wrong-peer" current-ad (lambda (target ad) #{ reason: 'authentication detail: "secret-ticket" }) (lambda (target) #{ connection: 'relay-carrier }) now-ms))) (assert-equal 'relay (dict-ref result transport: #f)) (assert-equal 'authentication (dict-ref result fallback-reason: #f)) (assert-false (dict-ref result detail: #f)))) (test "unknown failures are collapsed and relay failure stays explicit" (let ((result (node-dial "offline" current-ad (lambda (target ad) #{ reason: "ECONNRESET at 10.11.0.9" }) (lambda (target) #{ reason: 'network }) now-ms))) (assert-equal 'unavailable (dict-ref result transport: #f)) (assert-equal 'unavailable (dict-ref result fallback-reason: #f)) (assert-equal 'network (dict-ref result relay-failure: #f)))) )packages/slate-cli/test/test-node-direct.sgladded
(import (sigil test) (sigil core) (slate-cli node direct))(define message (list 'direct-ticket-message "caller" "phone" "req-7" "ws://10.11.0.9:32644/direct" "caller-secret" "acceptor-secret" "15"))(define extended-message (append message (list "41" "caller-static" "2" "caller-public" "42" "acceptor-static" "3" "acceptor-public")))(test-group "direct node peer authentication" (test "ticket parsing pins role, peer, endpoint, and absolute expiry" (let ((ticket (direct-ticket-from-message message 100))) (assert-equal 'caller (dict-ref ticket role: #f)) (assert-equal "phone" (dict-ref ticket peer: "")) (assert-equal 115 (dict-ref ticket expires-at: 0)) (assert-true (direct-ticket-current? ticket 114)) (assert-false (direct-ticket-current? ticket 115)))) (test "extended ticket retains authenticated bot and key generations" (let ((ticket (direct-ticket-from-message extended-message 100))) (assert-equal "41" (dict-ref ticket caller-bot-id: "")) (assert-equal 2 (dict-ref ticket caller-generation: 0)) (assert-equal "42" (dict-ref ticket acceptor-bot-id: "")) (assert-equal 3 (dict-ref ticket acceptor-generation: 0)))) (test "both peers prove distinct keys over the same bound transcript" (let* ((ticket (direct-ticket-from-message message 100)) (transcript (direct-ticket-transcript "laptop" "phone" "req-7" "nonce-c" "nonce-a")) (caller-proof (direct-caller-proof ticket transcript)) (acceptor-proof (direct-acceptor-proof ticket transcript))) (assert-false (string=? caller-proof acceptor-proof)) (assert-true (direct-verify-caller? ticket transcript caller-proof)) (assert-true (direct-verify-acceptor? ticket transcript acceptor-proof)) (assert-false (direct-verify-caller? ticket transcript acceptor-proof)))) (test "wrong peer identity changes the transcript and fails proof" (let* ((ticket (direct-ticket-from-message message 100)) (right (direct-ticket-transcript "laptop" "phone" "req-7" "nonce-c" "nonce-a")) (wrong (direct-ticket-transcript "mallory" "phone" "req-7" "nonce-c" "nonce-a"))) (assert-false (direct-verify-caller? ticket wrong (direct-caller-proof ticket right))))) (test "invalid role and excessive lifetime fail closed" (assert-false (direct-ticket-from-message (list 'direct-ticket-message "admin" "phone" "r" "ws://x" "a" "b" "15") 100)) (assert-false (direct-ticket-from-message (list 'direct-ticket-message "caller" "phone" "r" "ws://x" "a" "b" "300") 100))) (test "mutual handshake authenticates both peers before caller proof" (let* ((caller-ticket (direct-ticket-from-message message 100)) (acceptor-ticket (direct-ticket-from-message (list 'direct-ticket-message "acceptor" "laptop" "req-7" "ws://10.11.0.9:32644/direct" "caller-secret" "acceptor-secret" "15") 100)) (hello (direct-caller-hello "laptop" caller-ticket "nonce-c")) (state (direct-accept-challenge "phone" acceptor-ticket hello "nonce-a" 101)) (response (and state (direct-caller-response "laptop" caller-ticket "nonce-c" (dict-ref state challenge: #{}) 101)))) (assert-true (dict? state)) (assert-true (dict? response)) (assert-true (direct-accept-response acceptor-ticket state response 101)))) (test "only a well-formed hello addressed to this acceptor may await" (let ((hello #{ t: "direct/hello" version: 1 auth: "broker" caller: "laptop" acceptor: "phone" request-id: "req-7" caller-nonce: "nonce-c" } )) (assert-true (direct-broker-hello? hello "phone")) (assert-false (direct-broker-hello? (dict-set hello acceptor: "other") "phone")) (assert-false (direct-broker-hello? (dict-set hello caller-nonce: "") "phone")))) (test "wrong acceptor proof is refused without emitting caller proof" (let* ((ticket (direct-ticket-from-message message 100)) (challenge #{ t: "direct/challenge" version: 1 auth: "broker" request-id: "req-7" acceptor-nonce: "nonce-a" proof: "forged" })) (assert-false (direct-caller-response "laptop" ticket "nonce-c" challenge 101)))) (test "the dev instrument corrupts only caller proof messages" (let ((hello #{ t: "direct/hello" proof: "keep" }) (proof #{ t: "direct/proof" proof: "real" } )) (assert-equal hello (direct-test-mutate-control "invalid-proof" hello)) (assert-equal "real" (dict-ref (direct-test-mutate-control #f proof) proof: #f)) (assert-equal "invalid-test-proof" (dict-ref (direct-test-mutate-control "invalid-proof" proof) proof: #f)))) (test "the dev instrument changes only the caller identity in hello" (let ((hello #{ t: "direct/hello" caller: "laptop" acceptor: "phone" request-id: "req-7" caller-nonce: "nonce-c" }) (proof #{ t: "direct/proof" caller: "keep" } )) (let ((mutated (direct-test-mutate-control "wrong-peer" hello))) (assert-equal "wrong-test-peer" (dict-ref mutated caller: #f)) (assert-equal "phone" (dict-ref mutated acceptor: #f)) (assert-equal "req-7" (dict-ref mutated request-id: #f)) (assert-equal "nonce-c" (dict-ref mutated caller-nonce: #f))) (assert-equal proof (direct-test-mutate-control "wrong-peer" proof)))) (test "the dev instrument targets each paired authentication boundary" (let ((hello #{ t: "direct/hello" auth: "paired" caller-bot-id: "bot-a" caller-key-id: "key-a" caller-ephemeral: "point-a" }) (proof #{ t: "direct/proof" auth: "paired" signature: "signature" confirmation: "confirmation" })) (assert-equal "wrong-test-bot" (dict-ref (direct-test-mutate-control "paired-wrong-bot" hello) caller-bot-id: #f)) (assert-equal "wrong-test-key" (dict-ref (direct-test-mutate-control "paired-wrong-static-key" hello) caller-key-id: #f)) (assert-equal "not-a-p256-point" (dict-ref (direct-test-mutate-control "paired-malformed-ephemeral" hello) caller-ephemeral: #f)) (assert-equal "wrong-test-signature" (dict-ref (direct-test-mutate-control "paired-wrong-signature" proof) signature: #f)) (assert-equal "wrong-test-confirmation" (dict-ref (direct-test-mutate-control "paired-failed-confirmation" proof) confirmation: #f)) (assert-equal hello (direct-test-mutate-control "paired-wrong-signature" hello)))) (test "the expired-ticket instrument extends only the local expiry" (let* ((ticket (direct-ticket-from-message message 100)) (mutated (direct-test-ticket-for-attempt "expired-ticket" ticket 120))) (assert-equal 150 (dict-ref mutated expires-at: #f)) (assert-equal (dict-ref ticket caller-key: #f) (dict-ref mutated caller-key: #f)) (assert-equal (dict-ref ticket request-id: #f) (dict-ref mutated request-id: #f)) (assert-equal ticket (direct-test-ticket-for-attempt "wrong-peer" ticket 120)))) (test "ticket inbox replaces duplicates, expires entries, and consumes once" (let* ((store (make-direct-ticket-store)) (ticket (direct-ticket-from-message message 100))) (assert-true (direct-ticket-store-put! store ticket 101)) (assert-equal ticket (direct-ticket-store-find store 'caller "phone" "req-7" 101)) (assert-true (direct-ticket-store-consume! store ticket 101)) (assert-false (direct-ticket-store-find store 'caller "phone" "req-7" 101)) (assert-false (direct-ticket-store-consume! store ticket 101)) (assert-false (direct-ticket-store-put! store (direct-ticket-from-message message 100) 115)))) (test "acceptor can await a ticket that arrives after the direct hello" (let* ((store (make-direct-ticket-store)) (ticket (direct-ticket-from-message (list 'direct-ticket-message "acceptor" "laptop" "req-7" "ws://10.11.0.9:32644/direct" "caller-secret" "acceptor-secret" "15") 100)) (clock (vector 101000)) (pauses (vector 0)) (found (direct-ticket-store-await store 'acceptor "laptop" "req-7" (lambda () (let ((value (vector-ref clock 0))) (vector-set! clock 0 (+ value 10)) value)) 100 (lambda () (vector-set! pauses 0 (+ (vector-ref pauses 0) 1)) (when (= (vector-ref pauses 0) 2) (direct-ticket-store-put! store ticket 101)))))) (assert-equal ticket found) (assert-equal 2 (vector-ref pauses 0)) ;; Awaiting does not consume the credential before proof validation. (assert-equal ticket (direct-ticket-store-find store 'acceptor "laptop" "req-7" 101)))) (test "acceptor ticket wait is bounded when no correlated ticket arrives" (let ((clock (vector 101000)) (pauses (vector 0))) (assert-false (direct-ticket-store-await (make-direct-ticket-store) 'acceptor "laptop" "missing" (lambda () (let ((value (vector-ref clock 0))) (vector-set! clock 0 (+ value 10)) value)) 25 (lambda () (vector-set! pauses 0 (+ (vector-ref pauses 0) 1))))) (assert-equal 2 (vector-ref pauses 0)))) (test "caller connector completes mutual proof and returns its socket" (let* ((ticket (direct-ticket-from-message message 100)) (sent (vector '())) (closed (vector #f)) (clock (vector 101000)) (transport #{ open: (lambda (endpoint) 'socket) send: (lambda (conn control) (vector-set! sent 0 (append (vector-ref sent 0) (list control))) #t) receive: (lambda (conn) (let ((out (vector-ref sent 0))) (cond ((= (length out) 1) (dict-ref (direct-accept-challenge "phone" (direct-ticket-from-message (list 'direct-ticket-message "acceptor" "laptop" "req-7" "ws://10.11.0.9:32644/direct" "caller-secret" "acceptor-secret" "15") 100) (car out) "acceptor-live" 101) challenge: #f)) ((= (length out) 2) #{ t: "direct/ok" version: 1 auth: "broker" transport: "direct" }) (else #f)))) close: (lambda (conn) (vector-set! closed 0 #t)) now-ms: (lambda () (let ((value (vector-ref clock 0))) (vector-set! clock 0 (+ value 5)) value)) pause: (lambda () #t) }) (result (direct-caller-connect "laptop" ticket transport 100))) (assert-equal 'socket (dict-ref result connection: #f)) (assert-equal 2 (length (vector-ref sent 0))) (assert-false (vector-ref closed 0)))) (test "caller connector closes a bad acceptor proof" (let* ((ticket (direct-ticket-from-message message 100)) (closed (vector #f)) (clock (vector 101000)) (transport #{ open: (lambda (endpoint) 'socket) send: (lambda (conn control) #t) receive: (lambda (conn) #{ t: "direct/challenge" version: 1 auth: "broker" request-id: "req-7" acceptor-nonce: "bad" proof: "wrong" }) close: (lambda (conn) (vector-set! closed 0 #t)) now-ms: (lambda () (let ((value (vector-ref clock 0))) (vector-set! clock 0 (+ value 5)) value)) }) (result (direct-caller-connect "laptop" ticket transport 100))) (assert-equal 'authentication (dict-ref result reason: #f)) (assert-false (dict-ref result connection: #f)) (assert-true (vector-ref closed 0)))) (test "caller connector bounds an unresponsive endpoint" (let* ((ticket (direct-ticket-from-message message 100)) (closed (vector #f)) (clock (vector 101000)) (transport #{ open: (lambda (endpoint) 'socket) send: (lambda (conn control) #t) receive: (lambda (conn) #f) close: (lambda (conn) (vector-set! closed 0 #t)) now-ms: (lambda () (let ((value (vector-ref clock 0))) (vector-set! clock 0 (+ value 5)) value)) pause: (lambda () #t) }) (result (direct-caller-connect "laptop" ticket transport 20))) (assert-equal 'timeout (dict-ref result reason: #f)) (assert-true (vector-ref closed 0)))) )packages/slate-cli/test/test-node-gateway.sgladded
(import (sigil test) (slate-cli node gateway) (slate-cli wire))(test-group "node gateway multiplexing" (test "binary envelope preserves carrier id and original frame bytes" (let* ((inner (bytevector #x03 0 0 0 7 65 66 67)) (encoded (gateway-frame-encode 42 inner)) (decoded (gateway-frame-decode encoded))) (assert-equal GATEWAY-FRAME (bytevector-u8-ref encoded 0)) (assert-equal 42 (dict-ref decoded carrier: #f)) (assert-equal inner (dict-ref decoded payload: #f)))) (test "local node frames and malformed carrier zero are not gateway frames" (assert-false (gateway-frame-decode (bytevector #x03 0 0 0 7 65))) (assert-false (gateway-frame-decode (bytevector GATEWAY-FRAME 0 0 0 0 65)))) (test "caller receive timestamp extends only capture trace replies" (let* ((payload (bytevector 0 0 0 7 0 0 0 2 0 0 0 9)) (trace (encode-stream-frame frame-trace-out 31 payload)) (stamped (gateway-stamp-trace-received trace 4294967301)) (decoded (decode-stream-frame stamped)) (out (stream-frame-payload decoded)) (data (encode-stream-frame frame-data-out 31 (bytevector 65)))) (assert-equal frame-trace-out (stream-frame-type decoded)) (assert-equal 31 (stream-frame-id decoded)) (assert-equal 16 (bytevector-length out)) (assert-equal payload (bytevector-copy out 0 12)) (assert-equal (bytevector 0 0 0 5) (bytevector-copy out 12 16)) (let* ((with-outbound (gateway-stamp-trace-received trace 4294967301 4294967299)) (with-payload (stream-frame-payload (decode-stream-frame with-outbound)))) (assert-equal 20 (bytevector-length with-payload)) (assert-equal payload (bytevector-copy with-payload 0 12)) (assert-equal (bytevector 0 0 0 3 0 0 0 5) (bytevector-copy with-payload 12 20))) (assert-equal data (gateway-stamp-trace-received data 42)) (assert-equal (bytevector 1 2 3) (gateway-stamp-trace-received (bytevector 1 2 3) 42)))) (test "text envelope preserves opaque inner JSON" (let ((env (gateway-text-envelope 9 "{\"t\":\"req\",\"id\":1,\"verb\":\"session/open\"}"))) (assert-true (gateway-text-envelope? env)) (assert-equal 9 (dict-ref env carrier: #f)) (assert-equal "{\"t\":\"req\",\"id\":1,\"verb\":\"session/open\"}" (dict-ref env payload: #f)))) (test "registry allocates distinct opening carriers and binds one transport" (let* ((registry (make-gateway-registry)) (first (gateway-register! registry "phone")) (second (gateway-register! registry "laptop")) (bound (gateway-bind! registry (dict-ref first carrier: 0) 'socket 'direct #f))) (assert-equal 2 (gateway-count registry)) (assert-false (= (dict-ref first carrier: 0) (dict-ref second carrier: 0))) (assert-equal "g1" (dict-ref first request-id: #f)) (assert-equal 'ready (dict-ref bound state: #f)) (assert-equal 'direct (dict-ref bound transport: #f)) (assert-equal 'socket (dict-ref bound connection: #f)))) (test "relay binding records only the public fallback class" (let* ((registry (make-gateway-registry)) (entry (gateway-register! registry "phone")) (bound (gateway-bind! registry (dict-ref entry carrier: 0) 'relay-socket 'relay 'timeout))) (assert-equal 'relay (dict-ref bound transport: #f)) (assert-equal 'timeout (dict-ref bound fallback-reason: #f)))) (test "removal is atomic before the connection close callback" (let* ((registry (make-gateway-registry)) (entry (gateway-register! registry "phone")) (carrier (dict-ref entry carrier: 0)) (saw-missing #f)) (gateway-bind! registry carrier 'socket 'direct #f) (gateway-remove! registry carrier (lambda (connection) (set! saw-missing (not (gateway-ref registry carrier))))) (assert-true saw-missing) (assert-equal 0 (gateway-count registry)) (assert-false (gateway-ref registry carrier)))) (test "paired invalidation closes only matching paired-auth carriers" (let* ((registry (make-gateway-registry)) (phone (gateway-register! registry "phone")) (tablet (gateway-register! registry "tablet")) (broker (gateway-register! registry "phone")) (closed '())) (gateway-bind! registry (dict-ref phone carrier: 0) 'phone-socket 'direct #f 'paired) (gateway-bind! registry (dict-ref tablet carrier: 0) 'tablet-socket 'direct #f 'paired) (gateway-bind! registry (dict-ref broker carrier: 0) 'broker-socket 'relay #f 'broker) (assert-equal 1 (gateway-remove-paired! registry "phone" (lambda (socket) (set! closed (cons socket closed))))) (assert-equal '(phone-socket) closed) (assert-equal 2 (gateway-count registry)) (assert-equal 'paired (dict-ref (gateway-ref registry (dict-ref tablet carrier: 0)) auth-kind: #f)) (assert-equal 'broker (dict-ref (gateway-ref registry (dict-ref broker carrier: 0)) auth-kind: #f)))) (test "target identities are bounded tokens, never browser-supplied URLs" (assert-true (gateway-valid-target? "slate-node_123-abc")) (assert-false (gateway-valid-target? "")) (assert-false (gateway-valid-target? "ws://10.0.0.2/direct")) (assert-false (gateway-valid-target? "phone workstation"))) (test "carrier ownership gates close and disconnect removes the whole set" (let* ((registry (make-gateway-registry)) (owner-a (vector 'browser-a)) (owner-b (vector 'browser-b)) (a1 (gateway-register! registry "phone" #{ owner: owner-a request-id: 7 })) (a2 (gateway-register! registry "tablet" #{ owner: owner-a })) (b1 (gateway-register! registry "laptop" #{ owner: owner-b }))) (assert-true (gateway-owned? registry (dict-ref a1 carrier: 0) owner-a)) (assert-false (gateway-owned? registry (dict-ref a1 carrier: 0) owner-b)) (assert-equal 2 (length (gateway-remove-owner! registry owner-a))) (assert-equal 1 (gateway-count registry)) (assert-equal b1 (gateway-ref registry (dict-ref b1 carrier: 0))) (assert-false (gateway-ref registry (dict-ref a2 carrier: 0))))) (test "caller ticket correlation pins role, request id, peer, and opening state" (let* ((registry (make-gateway-registry)) (owner (vector 'browser)) (entry (gateway-register! registry "phone" #{ owner: owner })) (ticket #{ role: 'caller peer: "phone" request-id: "g1" })) (assert-equal entry (gateway-ticket-entry registry ticket)) (assert-false (gateway-ticket-entry registry #{ role: 'acceptor peer: "phone" request-id: "g1" })) (assert-false (gateway-ticket-entry registry #{ role: 'caller peer: "tablet" request-id: "g1" })) (gateway-bind! registry 1 'socket 'direct #f) (assert-false (gateway-ticket-entry registry ticket)))) (test "direct failure transitions once to fresh-relay acquisition" (let* ((registry (make-gateway-registry)) (entry (gateway-register! registry "phone" #{ owner: 'browser })) (relay (gateway-mark-relay! registry (dict-ref entry carrier: 0) 'timeout))) (assert-equal 'opening-relay (dict-ref relay state: #f)) (assert-equal relay (gateway-ticket-entry registry #{ role: 'caller peer: "phone" request-id: "g1" })) (assert-equal 'timeout (dict-ref relay fallback-reason: #f)) (assert-false (gateway-mark-relay! registry (dict-ref entry carrier: 0) 'network)))) (test "opening carriers have a bounded deadline" (let* ((registry (make-gateway-registry)) (entry (gateway-register! registry "phone" #{ opened-at: 100 }))) (assert-false (gateway-opening-expired? entry 104 5)) (assert-true (gateway-opening-expired? entry 105 5)) (let ((relay (gateway-mark-relay! registry 1 'standby))) (assert-true (gateway-opening-expired? relay 106 5))) (let ((ready (gateway-bind! registry 1 'socket 'relay 'standby))) (assert-false (gateway-opening-expired? ready 1000 5))))) (test "opened and failed events expose no carrier internals or secrets" (let* ((registry (make-gateway-registry)) (entry (gateway-register! registry "phone" #{ owner: 'browser request-id: "private-correlation" client-request: 81 })) (ready (gateway-bind! registry (dict-ref entry carrier: 0) 'secret-socket 'relay 'timeout)) (opened (gateway-opened-event ready)) (failed (gateway-failed-event entry 'timeout 'network))) (assert-equal "relay" (dict-ref opened transport: #f)) (assert-equal 81 (dict-ref opened request: #f)) (assert-equal 81 (dict-ref failed request: #f)) (assert-equal "timeout" (dict-ref opened fallback-reason: #f)) (assert-false (dict-ref opened connection: #f)) (assert-false (dict-ref opened owner: #f)) (assert-false (dict-ref opened target: #f)) (assert-false (dict-ref opened request-id: #f)) (assert-equal "unavailable" (dict-ref failed reason: #f)))) (test "relay completion pins sender, correlation, lifecycle, and bounded ttl" (let* ((registry (make-gateway-registry)) (entry (gateway-register! registry "phone" #{ owner: 'browser })) (_relay (gateway-mark-relay! registry 1 'timeout)) (completion (gateway-relay-completion registry "phone" "g1" "stream/ticket" #{ ticket: "relay-secret" expires-in: "30" pairing-descriptors: '("11" "ck" "1" "cp" "22" "ak" "1" "ap") } 100))) (assert-equal (dict-ref entry carrier: #f) (dict-ref (dict-ref completion entry: #{}) carrier: #f)) (assert-equal 'opening-relay (dict-ref (dict-ref completion entry: #{}) state: #f)) (assert-equal "relay-secret" (dict-ref completion ticket: #f)) (assert-equal 130 (dict-ref completion expires-at: 0)) (assert-equal '("11" "ck" "1" "cp" "22" "ak" "1" "ap") (dict-ref completion pairing-descriptors: #f)) (assert-false (gateway-relay-completion registry "tablet" "g1" "stream/ticket" #{ ticket: "x" expires-in: "30" } 100)) (assert-false (gateway-relay-completion registry "phone" "g1" "stream/ticket" #{ ticket: "x" expires-in: "300" } 100)))) )packages/slate-cli/test/test-node-paired-direct.sgladded
(import (sigil test) (sigil core) (slate-cli node pairing) (slate-cli node direct))(define caller-id (pairing-make-identity 1 100))(define acceptor-id (pairing-make-identity 1 100))(define (peer bot nick identity . revoked) #{ bot-id: bot transport-nick: nick peer-key-id: (dict-ref identity key-id: "") peer-generation: (dict-ref identity generation: 0) peer-public-key: (dict-ref identity public-key: "") endpoints: '("ws://10.0.0.2:32644/node/direct") created: 100 revoked: (and (pair? revoked) (car revoked)) })(define caller-peer (peer "bot-b" "node-b" acceptor-id))(define acceptor-peer (peer "bot-a" "node-a" caller-id))(define (exchange) (let* ((c0 (paired-direct-make 'caller "bot-a" caller-id caller-peer "cn")) (a0 (paired-direct-make 'acceptor "bot-b" acceptor-id acceptor-peer "an" "conn")) (h (paired-direct-start c0)) (a1 (paired-direct-handle a0 (dict-ref h send: #f))) (c1 (paired-direct-handle (dict-ref h state: #f) (dict-ref a1 send: #f))) (a2 (paired-direct-handle (dict-ref a1 state: #f) (dict-ref c1 send: #f))) (c2 (paired-direct-handle (dict-ref c1 state: #f) (dict-ref a2 send: #f)))) (list h a1 c1 a2 c2)))(test-group "unified v1 paired direct authentication" (test "both peers authenticate only after key confirmation" (let ((steps (exchange))) (assert-false (dict-ref (list-ref steps 0) authed?: #t)) (assert-false (dict-ref (list-ref steps 1) authed?: #t)) (assert-false (dict-ref (list-ref steps 2) authed?: #t)) (assert-true (dict-ref (list-ref steps 3) authed?: #f)) (assert-true (dict-ref (list-ref steps 4) authed?: #f)) (assert-equal 'active (paired-direct-phase (dict-ref (list-ref steps 4) state: #{}))))) (test "paired mode is explicit and broker messages are not reinterpreted" (assert-true (paired-direct-message? #{ t: "direct/hello" version: 1 auth: "paired" })) (assert-false (paired-direct-message? #{ t: "direct/hello" version: 1 auth: "broker" }))) (test "wrong static identity descriptor fails before challenge" (let* ((c0 (paired-direct-make 'caller "bot-a" caller-id caller-peer "cn")) (a0 (paired-direct-make 'acceptor "bot-b" acceptor-id acceptor-peer "an" "conn")) (h (paired-direct-start c0)) (bad (dict-set (dict-ref h send: #{}) caller-key-id: "wrong")) (out (paired-direct-handle a0 bad))) (assert-equal 'failed (paired-direct-phase (dict-ref out state: #{}))) (assert-false (dict-ref out send: #f)))) (test "wrong acceptor signature reveals no caller proof" (let* ((c0 (paired-direct-make 'caller "bot-a" caller-id caller-peer "cn")) (a0 (paired-direct-make 'acceptor "bot-b" acceptor-id acceptor-peer "an" "conn")) (h (paired-direct-start c0)) (a1 (paired-direct-handle a0 (dict-ref h send: #f))) (bad (dict-set (dict-ref a1 send: #{}) signature: "forged")) (out (paired-direct-handle (dict-ref h state: #{}) bad))) (assert-equal 'failed (paired-direct-phase (dict-ref out state: #{}))) (assert-false (dict-ref out send: #f)))) (test "replayed challenge does not advance an active caller" (let* ((steps (exchange)) (active (dict-ref (list-ref steps 4) state: #{})) (challenge (dict-ref (list-ref steps 1) send: #{})) (out (paired-direct-handle active challenge))) (assert-equal 'failed (paired-direct-phase (dict-ref out state: #{}))))) (test "partial state is discarded on disconnect" (let* ((c0 (paired-direct-make 'caller "bot-a" caller-id caller-peer "cn")) (started (dict-ref (paired-direct-start c0) state: #{}))) (assert-false (paired-direct-disconnect started)))) (test "caller connector authenticates a stored endpoint" (let* ((sent (vector '())) (acceptor (vector (paired-direct-make 'acceptor "bot-b" acceptor-id acceptor-peer "an" "conn"))) (closed (vector #f)) (clock (vector 1000)) (transport #{ open: (lambda (endpoint) 'socket) send: (lambda (conn message) (vector-set! sent 0 (append (vector-ref sent 0) (list message))) #t) receive: (lambda (conn) (let ((messages (vector-ref sent 0))) (and (pair? messages) (let ((out (paired-direct-handle (vector-ref acceptor 0) (car (reverse messages))))) (vector-set! acceptor 0 (dict-ref out state: #{})) (dict-ref out send: #f))))) close: (lambda (conn) (vector-set! closed 0 #t)) now-ms: (lambda () (let ((now (vector-ref clock 0))) (vector-set! clock 0 (+ now 5)) now)) pause: (lambda () #t) }) (result (paired-direct-caller-connect "bot-a" caller-id caller-peer transport 100))) (assert-equal 'socket (dict-ref result connection: #f)) (assert-false (vector-ref closed 0)))) (test "caller connector refuses an empty cached endpoint set" (let ((result (paired-direct-caller-connect "bot-a" caller-id (dict-set caller-peer endpoints: '()) #{} 100))) (assert-equal 'unavailable (dict-ref result reason: #f)))) (test "caller waits for native transport readiness before first hello" (let* ((sent (vector '())) (acceptor (vector (paired-direct-make 'acceptor "bot-b" acceptor-id acceptor-peer "an" "conn"))) (ready (vector #f)) (polls (vector 0)) (clock (vector 1000)) (transport #{ open: (lambda (endpoint) 'socket) connected?: (lambda (conn) (vector-ref ready 0)) send: (lambda (conn message) (and (vector-ref ready 0) (begin (vector-set! sent 0 (append (vector-ref sent 0) (list message))) #t))) receive: (lambda (conn) (if (not (vector-ref ready 0)) (begin (vector-set! polls 0 (+ 1 (vector-ref polls 0))) (vector-set! ready 0 #t) #f) (let ((messages (vector-ref sent 0))) (and (pair? messages) (let ((out (paired-direct-handle (vector-ref acceptor 0) (car (reverse messages))))) (vector-set! acceptor 0 (dict-ref out state: #{})) (dict-ref out send: #f)))))) close: (lambda (conn) #t) now-ms: (lambda () (let ((now (vector-ref clock 0))) (vector-set! clock 0 (+ now 5)) now)) pause: (lambda () #t) }) (result (paired-direct-caller-connect "bot-a" caller-id caller-peer transport 100))) (assert-equal 1 (vector-ref polls 0)) (assert-equal 'socket (dict-ref result connection: #f)))) (test "revoked pairing cannot construct an authentication state" (assert-error (paired-direct-make 'caller "bot-a" caller-id (peer "bot-b" "node-b" acceptor-id #t) "cn"))))packages/slate-cli/test/test-node-pairing-control.sgladded
(import (sigil core) (sigil test) (slate-cli node pairing) (slate-cli node pairing-control))(define caller-id (pairing-make-identity 1 100))(define acceptor-id (pairing-make-identity 1 100))(define context #{ session-id: "broker-session-7" caller-bot-id: "11" caller-transport-nick: "alpha" caller-key-id: (dict-ref caller-id key-id: "") caller-generation: 1 caller-public-key: (dict-ref caller-id public-key: "") acceptor-bot-id: "22" acceptor-transport-nick: "beta" acceptor-key-id: (dict-ref acceptor-id key-id: "") acceptor-generation: 1 acceptor-public-key: (dict-ref acceptor-id public-key: "") })(define (exchange) (let* ((c0 (pairing-control-make 'caller context caller-id (list "wss://beta.example:32644") "caller-nonce" 100)) (a0 (pairing-control-make 'acceptor context acceptor-id (list "wss://alpha.example:32644") "acceptor-nonce" 100)) (o (pairing-control-start c0)) (a1 (pairing-control-handle a0 (dict-ref o send: #f))) (c1 (pairing-control-handle (dict-ref o state: #f) (dict-ref a1 send: #f))) (a2 (pairing-control-handle (dict-ref a1 state: #f) (dict-ref c1 send: #f))) (c2 (pairing-control-handle (dict-ref c1 state: #f) (dict-ref a2 send: #f)))) (list o a1 c1 a2 c2)))(define (default-exchange) (let* ((c0 (pairing-control-make 'caller context caller-id '())) (a0 (pairing-control-make 'acceptor context acceptor-id '())) (o (pairing-control-start c0)) (a1 (pairing-control-handle a0 (dict-ref o send: #f))) (c1 (pairing-control-handle (dict-ref o state: #f) (dict-ref a1 send: #f))) (a2 (pairing-control-handle (dict-ref a1 state: #f) (dict-ref c1 send: #f))) (c2 (pairing-control-handle (dict-ref c1 state: #f) (dict-ref a2 send: #f)))) (list a2 c2)))(test-group "four-message node pairing control" (test "default construction generates a nonce" (let* ((state (pairing-control-make 'caller context caller-id '())) (offer (dict-ref (pairing-control-start state) send: #f)) (nonce (and offer (dict-ref offer nonce: #f)))) (assert-true (string? nonce)) (assert-equal 43 (string-length nonce)))) (test "default construction commits valid records" (let ((xs (default-exchange))) (assert-true (pairing-record-valid? (pairing-control-record (dict-ref (list-ref xs 0) state: #f)))) (assert-true (pairing-record-valid? (pairing-control-record (dict-ref (list-ref xs 1) state: #f)))))) (test "only committed endpoints activate" (let ((xs (exchange))) (assert-false (dict-ref (list-ref xs 0) activate?: #t)) (assert-false (dict-ref (list-ref xs 1) activate?: #t)) (assert-false (dict-ref (list-ref xs 2) activate?: #t)) (assert-true (dict-ref (list-ref xs 3) activate?: #f)) (assert-true (dict-ref (list-ref xs 4) activate?: #f)))) (test "disconnect discards every pending phase" (let ((xs (exchange))) (assert-false (pairing-control-disconnect (dict-ref (list-ref xs 0) state: #f))) (assert-false (pairing-control-disconnect (dict-ref (list-ref xs 1) state: #f))) (assert-false (pairing-control-disconnect (dict-ref (list-ref xs 2) state: #f))) (assert-true (pairing-record-valid? (pairing-control-record (pairing-control-disconnect (dict-ref (list-ref xs 3) state: #f))))))) (test "carrier does not affect record bytes" (let* ((direct (exchange)) (relay (exchange)) (d (pairing-control-record (dict-ref (list-ref direct 4) state: #f))) (r (pairing-control-record (dict-ref (list-ref relay 4) state: #f)))) (assert-equal d r))) (test "tampered accept fails closed" (let* ((c0 (pairing-control-make 'caller context caller-id '() "c")) (a0 (pairing-control-make 'acceptor context acceptor-id '() "a")) (o (pairing-control-start c0)) (a1 (pairing-control-handle a0 (dict-ref o send: #f))) (bad (dict-set (dict-ref a1 send: #f) signature: "bad")) (c1 (pairing-control-handle (dict-ref o state: #f) bad))) (assert-equal 'failed (pairing-control-phase (dict-ref c1 state: #f))) (assert-false (dict-ref c1 activate?: #t)))))packages/slate-cli/test/test-node-pairing-management.sgladded
(import (sigil test) (sigil core) (sigil fs) (sigil path) (sigil process) (slate-cli node pairing) (slate-cli node pairing-management))(define (record bot nick identity endpoints) #{ bot-id: bot transport-nick: nick peer-key-id: (dict-ref identity key-id: "") peer-generation: (dict-ref identity generation: 0) peer-public-key: (dict-ref identity public-key: "") endpoints: endpoints created: 100 revoked: #f })(test-group "durable pairing management" (test "list reconstructs safe readiness summaries" (let* ((dir (string-append "/tmp/slate-pair-manage-" (number->string (process-id)))) (identity (pairing-make-identity 1 100)) (peer (record "bot-b" "node-b" identity '("ws://10.0.0.2:32644/node/direct")))) (ensure-directory dir) (pairing-save-records! dir (list peer)) (let* ((items (pairing-management-list dir)) (item (car items))) (assert-true (dict-ref item offline-ready?: #f)) (assert-false (dict-ref item endpoints: #f)) (assert-false (dict-ref item peer-public-key: #f))))) (test "revoke tombstones by bot id and makes the record unusable" (let* ((dir (string-append "/tmp/slate-pair-revoke-" (number->string (process-id)))) (identity (pairing-make-identity 1 100)) (peer (record "bot-c" "node-c" identity '("ws://10.0.0.3:32644/node/direct")))) (ensure-directory dir) (pairing-save-records! dir (list peer)) (let ((summary (pairing-management-revoke! dir "bot-c"))) (assert-true (dict-ref summary revoked: #f)) (assert-false (dict-ref summary offline-ready?: #t)) (assert-false (pairing-record-find (pairing-load-records dir) "bot-c"))))) (test "rotation increments generation and atomically replaces key identity" (let* ((dir (string-append "/tmp/slate-pair-rotate-" (number->string (process-id)))) (old (pairing-make-identity 4 100))) (ensure-directory dir) (pairing-save-identity! dir old) (let* ((summary (pairing-management-rotate! dir)) (fresh (pairing-load-identity dir))) (assert-equal 5 (dict-ref summary generation: 0)) (assert-equal 5 (dict-ref fresh generation: 0)) (assert-false (equal? (dict-ref old key-id: #f) (dict-ref fresh key-id: #f))) (assert-false (dict-ref summary private-key: #f))))) (test "missing peers and corrupt state fail closed" (let ((dir (string-append "/tmp/slate-pair-missing-" (number->string (process-id))))) (ensure-directory dir) (pairing-save-records! dir '()) (assert-error (pairing-management-revoke! dir "missing")))))packages/slate-cli/test/test-node-pairing-readers.sgladded
(import (sigil test) (sigil fs) (sigil json) (sigil process) (sigil time) (slate-cli node pairing) (slate-cli node pairing-management))(define (test-store) (let ((dir (string-append "/tmp/slate-pairing-readers-" (number->string (current-jiffy))))) (ensure-directory dir) dir))(define (peer-record identity) #{ bot-id: "peer" transport-nick: "peer-nick" peer-key-id: (dict-ref identity key-id: "") peer-generation: (dict-ref identity generation: 0) peer-public-key: (dict-ref identity public-key: "") endpoints: '("ws://10.0.0.2:32644/node/direct") created: 100 revoked: #f })(define (fixed-mtime! path) (assert-equal 0 (process-run "touch" "-d" "@1700000000" path)))(test-group "pairing file readers" (test "unchanged valid contents reuse their validated snapshot" (let* ((dir (test-store)) (identity (pairing-make-identity 1 100)) (read-identity (make-pairing-identity-reader dir)) (read-records (make-pairing-records-reader dir))) (pairing-save-identity! dir identity) (pairing-save-records! dir (list (peer-record identity))) (let ((first-identity (read-identity)) (first-records (read-records))) (assert-equal identity first-identity) (assert-equal (list (peer-record identity)) first-records) (assert-true (eq? first-identity (read-identity))) (assert-true (eq? first-records (read-records))) ;; Replacing a file with the same contents is also safe to reuse. (pairing-save-identity! dir identity) (assert-true (eq? first-identity (read-identity)))))) (test "revocation and rotation are visible even with identical mtimes" (let* ((dir (test-store)) (identity (pairing-make-identity 1 100)) (read-identity (make-pairing-identity-reader dir)) (read-records (make-pairing-records-reader dir))) (pairing-save-identity! dir identity) (pairing-save-records! dir (list (peer-record identity))) (fixed-mtime! (pairing-identity-path dir)) (fixed-mtime! (pairing-records-path dir)) (assert-equal identity (read-identity)) (assert-false (dict-ref (car (read-records)) revoked: #t)) (pairing-management-revoke! dir "peer") (pairing-management-rotate! dir) (fixed-mtime! (pairing-identity-path dir)) (fixed-mtime! (pairing-records-path dir)) (assert-true (dict-ref (car (read-records)) revoked: #f)) (assert-equal 2 (dict-ref (read-identity) generation: 0)) (assert-false (equal? (dict-ref identity key-id: #f) (dict-ref (read-identity) key-id: #f))))) (test "changed keys are fully validated instead of trusting prior ids" (let* ((dir (test-store)) (identity (pairing-make-identity 1 100)) (other (pairing-make-identity 1 100)) (read-identity (make-pairing-identity-reader dir)) (read-records (make-pairing-records-reader dir))) (pairing-save-identity! dir identity) (pairing-save-records! dir (list (peer-record identity))) (assert-equal identity (read-identity)) (assert-true (pair? (read-records))) ;; Valid encoding and unchanged key id, but a mismatched private key. (write-file-string (pairing-identity-path dir) (json-encode (dict-set identity private-key: (dict-ref other private-key: "")))) (write-file-string (pairing-records-path dir) (json-encode #{ version: 1 pairings: (list (dict-set (peer-record identity) peer-public-key: "bad")) })) (assert-false (read-identity)) (assert-false (read-records)))) (test "invalid snapshots are discarded and valid restoration is reloaded" (let* ((dir (test-store)) (identity (pairing-make-identity 1 100)) (read-identity (make-pairing-identity-reader dir)) (read-records (make-pairing-records-reader dir))) (pairing-save-identity! dir identity) (pairing-save-records! dir (list (peer-record identity))) (let ((old-identity (read-identity)) (old-records (read-records))) (write-file-string (pairing-identity-path dir) "{broken") (write-file-string (pairing-records-path dir) "{broken") (assert-false (read-identity)) (assert-false (read-records)) (assert-false (read-identity)) (pairing-save-identity! dir identity) (pairing-save-records! dir (list (peer-record identity))) (let ((fresh-identity (read-identity)) (fresh-records (read-records))) (assert-equal old-identity fresh-identity) (assert-equal old-records fresh-records) (assert-false (eq? old-identity fresh-identity)) (assert-false (eq? old-records fresh-records)))))) (test "missing files and read errors never return a previous snapshot" (let* ((dir (test-store)) (identity (pairing-make-identity 1 100)) (read-identity (make-pairing-identity-reader dir)) (read-records (make-pairing-records-reader dir))) (assert-false (read-identity)) (assert-equal '() (read-records)) (pairing-save-identity! dir identity) (pairing-save-records! dir (list (peer-record identity))) (assert-equal identity (read-identity)) (assert-true (pair? (read-records))) (delete-file (pairing-identity-path dir)) (delete-file (pairing-records-path dir)) (assert-false (read-identity)) (assert-equal '() (read-records)) (ensure-directory (pairing-identity-path dir)) (ensure-directory (pairing-records-path dir)) (assert-false (read-identity)) (assert-false (read-records)) (delete-directory (pairing-identity-path dir)) (delete-directory (pairing-records-path dir)) (pairing-save-identity! dir identity) (pairing-save-records! dir '()) (assert-equal identity (read-identity)) (assert-equal '() (read-records)))) (test "readers are independent across stores and daemon lifetimes" (let* ((a (test-store)) (b (test-store)) (identity (pairing-make-identity 1 100)) (read-a (make-pairing-identity-reader a)) (read-b (make-pairing-identity-reader b))) (pairing-save-identity! a identity) (pairing-save-identity! b identity) (let ((first (read-a))) (assert-equal first (read-b)) (assert-false (eq? first (read-b))) (assert-false (eq? first ((make-pairing-identity-reader a))))) (pairing-management-rotate! b) (assert-equal 1 (dict-ref (read-a) generation: 0)) (assert-equal 2 (dict-ref (read-b) generation: 0)))))packages/slate-cli/test/test-node-pairing.sgladded
(import (sigil test) (sigil core) (sigil string) (sigil fs) (sigil path) (sigil json) (sigil process) (sigil time) (slate-cli node pairing))(define (record public key-id generation) #{ bot-id: "bot-2" transport-nick: "peer-transport" label: "Peer" peer-key-id: key-id peer-generation: generation peer-public-key: public endpoints: (list "ws://100.64.0.2:32644/node/direct") created: 100 last-connected: 101 last-enclave-validated: 102 revoked: #f })(test-group "durable node pairing primitives" (test "default identity timestamps validate in the native runtime" (assert-true (pairing-identity-valid? (pairing-make-identity 1)))) (test "transcript is ordered, length-prefixed, and identity-bound" (let ((a (pairing-transcript "s" "a" "an" "ak" 1 "b" "bn" "bk" 2 "ap" "bp" "cn" "an")) (b (pairing-transcript "s" "aa" "n" "ak" 1 "b" "bn" "bk" 2 "ap" "bp" "cn" "an"))) (assert-false (string=? a b)) (let ((prefix "21:slate-node-pairing-v1|1:s")) (assert-true (string=? (substring a 0 (string-length prefix)) prefix))))) (test "P-256 signatures and ephemeral confirmation agree" (let* ((a (pairing-generate-keypair)) (b (pairing-generate-keypair)) (msg "bound transcript") (sig (pairing-sign (dict-ref a private-key: "") msg)) (za (pairing-shared-secret (dict-ref a private-key: "") (dict-ref b public-key: ""))) (zb (pairing-shared-secret (dict-ref b private-key: "") (dict-ref a public-key: "")))) (assert-true (pairing-verify? (dict-ref a public-key: "") msg sig)) (assert-false (pairing-verify? (dict-ref b public-key: "") msg sig)) (assert-equal (pairing-confirmation-key za msg) (pairing-confirmation-key zb msg)))) (test "same-generation key conflict and stale generation fail closed" (let* ((a (pairing-generate-keypair)) (b (pairing-generate-keypair)) (old (record (dict-ref a public-key: "") "a" 2)) (conflict (record (dict-ref b public-key: "") "b" 2)) (stale (record (dict-ref a public-key: "") "a" 1))) (assert-equal 'conflict (pairing-record-relation old conflict)) (assert-equal 'stale (pairing-record-relation old stale)) (assert-false (pairing-record-merge (list old) conflict)) (assert-false (pairing-record-merge (list old) stale)))) (test "malformed keys, unsafe endpoints, and corruption are refused" (let* ((kp (pairing-generate-keypair)) (good (record (dict-ref kp public-key: "") "key" 1))) (assert-true (pairing-record-valid? good)) (assert-false (pairing-record-valid? (dict-set good peer-public-key: "bad"))) (assert-false (pairing-record-valid? (dict-set good endpoints: (list "ws://127.0.0.1:1/x")))))) (test "identity and pairing stores are atomic and owner-only" (let* ((dir (string-append "/tmp/slate-node-pairing-" (number->string (current-jiffy)))) (identity (pairing-make-identity 1 100)) (peer (record (dict-ref identity public-key: "") "peer-key" 1))) (guard (e (#t (raise e))) (pairing-save-identity! dir identity) (pairing-save-records! dir (list peer)) (assert-equal "700" (string-trim (process-output->string "stat" "-c" "%a" dir))) (assert-equal "600" (string-trim (process-output->string "stat" "-c" "%a" (pairing-identity-path dir)))) (assert-equal identity (pairing-load-identity dir)) (assert-equal (list peer) (pairing-load-records dir)) (assert-false (file-exists? (string-append (pairing-records-path dir) ".tmp")))))) (test "corrupt stores quarantine instead of returning partial trust" (let ((dir (string-append "/tmp/slate-node-pairing-corrupt-" (number->string (current-jiffy))))) (ensure-directory dir) (write-file-string (pairing-records-path dir) "{not json") (assert-false (pairing-load-records dir)))) (test "permission failure is fatal" (assert-error (pairing-require-mode! "/tmp/x" "600" exists?: (lambda (name) #f) run: (lambda args 0)))) (test "public output is reconstructed without private material or endpoints" (let* ((identity (pairing-make-identity 1 100)) (summary (pairing-public-summary identity)) (rendered (json-encode summary))) (assert-false (string-contains? rendered (dict-ref identity private-key: ""))) (assert-false (dict-ref summary private-key: #f)))))packages/slate-cli/test/test-pairing-threats.sglmodified
(assert-false (host-allowed? (list (cons "host" "evil.example")) 32640))) (test "the loopback authority is accepted" (assert-true (host-allowed? (list (cons "host" "127.0.0.1:32640")) 32640))) (test "the exact bare loopback literal used by the native client is accepted" (assert-true (host-allowed? (list (cons "host" "127.0.0.1")) 32640))) (test "localhost authority is accepted" (assert-true (host-allowed? (list (cons "host" "localhost:32640")) 32640))) (test "the right host but the wrong port is rejected"packages/slate-cli/test/test-persistent.sgladded
;;; Persistent Android/Termux node installation contract.(import (sigil test) (sigil core) (sigil string) (sigil fs) (sigil json) (sigil process) (slate-cli config) (slate-cli persistent) (slate-cli node-posture))(define test-home "/tmp/slate-persistent-test-home")(define test-prefix "/tmp/slate-persistent-test-prefix")(setenv! "SLATE_PERSIST_ROOT" test-home)(setenv! "SLATE_TERMUX_PREFIX" test-prefix)(test-group "persistent node layout and generated supervision" (let ((run (persistent-service-script)) (control (persistent-control-script)) (boot (persistent-boot-script)) (launcher (persistent-launcher-script))) (test "installation root is named node" (assert-equal "/tmp/slate-persistent-test-home/.local/share/slate/node" (persistent-root-dir))) (test "service uses the canonical node entrypoint on loopback" (assert-true (and (string-contains? run "slate\" node run") (not (string-contains? run "slate\" serve")) (string-contains? run "--host 127.0.0.1") (string-contains? run "--app-upstream \"$upstream\"")))) (test "stable CLI launcher follows only a safe active version" (assert-true (and (string-contains? launcher "cat \"$ROOT/active\"") (string-contains? launcher "*[!A-Za-z0-9._-]*") (string-contains? launcher "exec \"$binary\" \"$@\"")))) (test "supervisor forwards termination to its exact child" (assert-true (and (string-contains? run "trap stop_child TERM INT HUP") (string-contains? run "kill \"$child\"") (string-contains? run "wait \"$child\"")))) (test "supervisor atomically records exact child lifecycle" (assert-true (and (string-contains? run "LIFE=\"$ROOT/lifecycle\"") (string-contains? run "atomic \"$LIFE/child.pid\" \"$child\"") (string-contains? run "last-start-at") (string-contains? run "last-exit-status") (string-contains? run "restart-count")))) (test "supervisor startup records an unclean prior child" (assert-true (and (string-contains? run "last-exit-observed-at") (string-contains? run "unclean-supervisor-recovery") (string-contains? run "rm -f \"$LIFE/last-exit-at\"")))) (test "restart backoff is bounded and service output is retained" (assert-true (and (string-contains? run "delay=30") (string-contains? run "service-output.log")))) (test "control operations use Termux's explicit runit directory" (assert-true (and (string-contains? control "SVDIR=\"$PREFIX/var/service\"") (string-contains? control "start|stop|restart|status|logs")))) (test "structured status distinguishes runtime observations" (assert-true (and (string-contains? control "status-json) status_json") (string-contains? control "pidstate=stale") (string-contains? control "life=corrupt") (string-contains? control "available=false") (string-contains? control "healthy=false")))) (test "status JSON sanitizes corrupt multiline scalar state" (assert-true (string-contains? control "tr '\\r\\n' ' '"))) (test "generated control shell is valid and emits decodable JSON" (let ((script "/tmp/slate-persistent-control-test.sh") (output "/tmp/slate-persistent-control-test.json")) (write-file-string script control) (assert-equal 0 (process-run "sh" "-n" script)) (assert-equal 0 (process-run "sh" "-c" "sh /tmp/slate-persistent-control-test.sh status-json > /tmp/slate-persistent-control-test.json")) (let ((status (json-decode (read-file-string output)))) (assert-equal 3 (dict-ref status schema-version: 0)) (assert-equal "local-node" (dict-ref (dict-ref status identity: #{}) role: "")) (assert-equal "atomic-version-directory" (dict-ref (dict-ref (dict-ref status upgrade: #{}) installation: #{}) strategy: "")) (assert-equal local-node-version (dict-ref (dict-ref status upgrade: #{}) current-version: "")) (assert-false (dict-ref (dict-ref (dict-ref status upgrade: #{}) apply: #{}) available: #t)) (assert-false (dict-ref (dict-ref status persistence: #{}) installed: #t))))) (test "start waits for the exact loopback health endpoint" (assert-true (and (string-contains? control "/node/health") (string-contains? control "loopback health check timed out")))) (test "stop waits for runit and exact-child cleanup" (assert-true (and (string-contains? control "while running || [ -f \"$ROOT/lifecycle/child.pid\" ]") (string-contains? control "supervisor stop timed out")))) (test "boot starts services without opening a browser" (assert-true (and (string-contains? boot "start-services.sh") (string-contains? boot "slate-service start") (not (string-contains? boot "termux-open-url"))))) (test "boot records invocation and waits boundedly for runit" (assert-true (and (string-contains? boot "boot-invoked") (string-contains? boot "while ! sv status slate") (string-contains? boot "runit-readiness-timeout") (string-contains? boot "boot.log")))) (test "wake lock remains opt-in under the node root" (assert-true (string-contains? boot ".local/share/slate/node/wake-lock-enabled")))))(test-group "persistent install source" (test "explicit staged executable wins" (setenv! "SLATE_PERSIST_EXECUTABLE" "/tmp/staged-slate") (assert-equal "/tmp/staged-slate" (persistent-install-source)) (setenv! "SLATE_PERSIST_EXECUTABLE" "")) (test "native executable path replaces Linux-only procfs assumption" (assert-true (let ((source (persistent-install-source))) (and (string? source) (> (string-length source) 0) (not (string=? source "/proc/self/exe")))))))(test-group "OpenBSD rc.d service" (let ((rc (persistent-openbsd-rc-script "/home/daviwil" "daviwil"))) (test "runs the stable Slate command as the login user" (assert-true (and (string-contains? rc "daemon='/home/daviwil/.local/bin/slate'") (string-contains? rc "daemon_user='daviwil'") (string-contains? rc "daemon_execdir='/home/daviwil'")))) (test "tracks any atomically selected version" (assert-true (string-contains? rc "versions/.*/slate --log-level info node run"))) (test "uses foreground-safe rc.subr supervision and syslog" (assert-true (and (string-contains? rc "rc_bg=YES") (string-contains? rc "daemon_logger=\"daemon.info\"") (string-contains? rc ". /etc/rc.d/rc.subr") (string-contains? rc "rc_cmd $1"))))))(test-group "canonical local node identity" (define identity-dir "/tmp/slate-persistent-test-identity") (test "Enclave-compatible names are accepted locally" (assert-true (valid-local-node-name? "phone-1")) (assert-false (valid-local-node-name? "phone one")) (assert-false (valid-local-node-name? "local:")) (assert-false (valid-local-node-name? "@phone"))) (test "an explicit install name is durable" (let ((name (local-node-ensure-name! identity-dir "pocket"))) (assert-equal "pocket" name) (assert-equal "pocket" (local-node-name identity-dir)) (assert-equal "pocket" (config-machine-name (make-config state-dir: identity-dir))))) (test "an existing configured name wins over the ambient hostname" (setenv! "HOSTNAME" "ambient-host") (assert-equal "pocket" (local-node-ensure-name! identity-dir #f))) (test "a corrupt durable identity never falls back to hostname" (let ((corrupt-dir "/tmp/slate-persistent-test-corrupt-identity")) (ensure-directory corrupt-dir) (write-file-string (local-node-name-path corrupt-dir) "local:\n") (assert-error (local-node-name corrupt-dir)))))(test-group "persistent status JSON schema" (let ((status (persistent-status-schema))) (test "top-level contract is versioned and typed" (assert-equal 3 (dict-ref status schema-version: 0)) (assert-false (dict-ref (dict-ref status persistence: #{}) installed: #t)) (assert-equal "not-installed" (dict-ref (dict-ref status persistence: #{}) posture: ""))) (test "installed running healthy and boot are independent" (assert-false (dict-ref (dict-ref status supervisor: #{}) running: #t)) (assert-false (dict-ref (dict-ref status health: #{}) healthy: #t)) (assert-false (dict-ref (dict-ref status boot: #{}) configured: #t))) (test "unknown Android state remains explicit" (let ((android (dict-ref status android: #{}))) (assert-equal "unknown" (dict-ref android persistence-posture: "")) (assert-equal "unknown" (dict-ref android autostart: "")) (assert-equal "unknown" (dict-ref android background: "")) (assert-equal "unknown" (dict-ref android battery-exemption: "")))) (test "lifecycle defaults distinguish missing state" (let ((life (dict-ref status lifecycle: #{}))) (assert-equal "missing" (dict-ref life state: "")) (assert-false (dict-ref life last-start: #t)) (assert-false (dict-ref life last-exit: #t)) (assert-equal 0 (dict-ref life restart-count: -1))))))(test-group "persistent status state matrix" (define (section observations key) (dict-ref (persistent-status-from-observations observations) key #{})) (test "installed but stopped" (let ((s (persistent-status-from-observations #{ installed: #t supervisor-available: #t lifecycle-state: "valid" }))) (assert-true (dict-ref (dict-ref s persistence: #{}) installed: #f)) (assert-false (dict-ref (dict-ref s supervisor: #{}) running: #t)) (assert-equal "absent" (dict-ref (dict-ref s process: #{}) pid-state: "")))) (test "running healthy exact PID" (let ((s (persistent-status-from-observations #{ installed: #t supervisor-available: #t supervisor-running: #t pid: 4242 pid-live: #t healthy: #t }))) (assert-equal "exact" (dict-ref (dict-ref s process: #{}) pid-state: "")) (assert-true (dict-ref (dict-ref s health: #{}) healthy: #f)))) (test "running unhealthy is not collapsed" (let ((s (persistent-status-from-observations #{ supervisor-running: #t pid: 4242 pid-live: #t healthy: #f }))) (assert-true (dict-ref (dict-ref s supervisor: #{}) running: #f)) (assert-false (dict-ref (dict-ref s health: #{}) healthy: #t)))) (test "stale PID" (assert-equal "stale" (dict-ref (section #{ pid: 4242 pid-live: #f } process:) pid-state: ""))) (test "missing supervisor" (assert-false (dict-ref (section #{ installed: #t supervisor-available: #f } supervisor:) available: #t))) (test "Termux boot configuration changes only Android user-managed posture" (let ((s (persistent-status-from-observations #{ platform-kind: "android" environment: "termux" boot-configured: #t }))) (assert-true (dict-ref (dict-ref s boot: #{}) configured: #f)) (assert-equal "user-managed" (dict-ref (dict-ref s android: #{}) persistence-posture: "")))) (test "corrupt lifecycle remains corrupt" (assert-equal "corrupt" (dict-ref (section #{ lifecycle-state: "corrupt" } lifecycle:) state: ""))) (test "restart bookkeeping and exit detail survive" (let* ((last #{ at: "2026-08-30T12:00:00+03:00" status: 159 reason: "exited" }) (life (section #{ restart-count: 7 last-exit: last } lifecycle:))) (assert-equal 7 (dict-ref life restart-count: 0)) (assert-equal 159 (dict-ref (dict-ref life last-exit: #{}) status: 0)))) (test "unclean recovery can carry unknown exit time and status" (let* ((last #{ at: #f observed-at: "2026-08-30T17:00:27+03:00" status: #f reason: "unclean-supervisor-recovery" }) (life (section #{ restart-count: 2 last-exit: last } lifecycle:))) (assert-false (dict-ref (dict-ref life last-exit: #{}) status: #t)) (assert-equal 2 (dict-ref life restart-count: 0)))))(test-group "cross-platform local node status" (test "missing control script falls back to native desktop status" (let ((prefix (getenv "SLATE_TERMUX_PREFIX")) (termux-version (getenv "TERMUX_VERSION"))) (setenv! "SLATE_TERMUX_PREFIX" #f) (setenv! "TERMUX_VERSION" #f) (let* ((s (persistent-fallback-status)) (platform (dict-ref s platform: #{})) (supervision (dict-ref s supervision: #{}))) (assert-equal "desktop" (dict-ref platform kind: "")) (assert-equal "native" (dict-ref platform environment: "")) (assert-equal "none" (dict-ref supervision kind: ""))) (setenv! "SLATE_TERMUX_PREFIX" prefix) (setenv! "TERMUX_VERSION" termux-version))) (test "missing control script retains Termux status on Android" (let* ((s (persistent-fallback-status)) (platform (dict-ref s platform: #{})) (supervision (dict-ref s supervision: #{}))) (assert-equal "android" (dict-ref platform kind: "")) (assert-equal "termux" (dict-ref platform environment: "")) (assert-equal "runit" (dict-ref supervision kind: "")))) (test "foreground desktop is healthy without pretending to be supervised" (let* ((s (persistent-status-from-observations #{ platform-kind: "linux" environment: "native" supervision-kind: "none" pid: 4242 pid-live: #t healthy: #t })) (platform (dict-ref s platform: #{})) (supervision (dict-ref s supervision: #{}))) (assert-equal "native" (dict-ref platform environment: "")) (assert-equal "none" (dict-ref supervision kind: "")) (assert-false (dict-ref supervision running: #t)) (assert-true (dict-ref (dict-ref s listener: #{}) healthy: #f)))) (test "supervised desktop uses the common contract" (let* ((s (persistent-status-from-observations #{ platform-kind: "linux" environment: "native" supervision-kind: "systemd" supervisor-available: #t supervisor-running: #t boot-configured: #t })) (details (dict-ref (dict-ref s platform-details: #{}) desktop: #{}))) (assert-equal "systemd" (dict-ref details service-manager: "")) (assert-equal "configured" (dict-ref (dict-ref s autostart: #{}) posture: "")) (assert-equal "unknown" (dict-ref (dict-ref s android: #{}) persistence-posture: "")))) (test "Termux details do not shape the shared supervision fields" (let* ((s (persistent-status-from-observations #{ platform-kind: "android" environment: "termux" supervision-kind: "runit" supervisor-available: #t supervisor-running: #t boot-configured: #t wake-lock: "enabled" })) (termux (dict-ref (dict-ref s platform-details: #{}) termux: #{}))) (assert-equal "runit" (dict-ref (dict-ref s supervision: #{}) kind: "")) (assert-equal "enabled" (dict-ref termux wake-lock: "")) (assert-equal "user-managed" (dict-ref termux android-persistence-posture: "")))) (test "status serialization preserves schema version" (let ((decoded (json-decode (persistent-status-json (persistent-status-schema))))) (assert-equal 3 (dict-ref decoded schema-version: 0)))))(test-group "local-node role and capability posture" (test "role is local-node on every platform" (for-each (lambda (environment) (let ((status (persistent-status-from-observations #{ environment: environment }))) (assert-equal "local-node" (dict-ref (dict-ref status identity: #{}) role: "")))) '("native" "termux"))) (test "implemented local planes are declared independently of grants" (let ((posture (local-node-capability-posture))) (for-each (lambda (key) (assert-true (dict-ref (dict-ref posture key #{}) available: #f))) (list resource: session: agent: renderer:)))) (test "remote transport management is explicitly unavailable" (let ((remote (dict-ref (local-node-capability-posture) remote-transport-manager: #{}))) (assert-false (dict-ref remote available: #t)) (assert-equal "not-implemented" (dict-ref remote reason: "")))) (test "closed policy does not admit a remote transport family" (assert-true (local-node-allows-verb? "fs/read-file")) (assert-true (local-node-allows-verb? "git/status")) (assert-true (local-node-allows-verb? "git/switch-branch")) (assert-true (local-node-allows-verb? "session/open")) (assert-false (local-node-allows-verb? "remote/dial"))))(test-group "local-node upgrade posture" (test "desktop and Android share the read-only upgrade contract" (for-each (lambda (fixture) (let ((upgrade (dict-ref fixture upgrade: #{}))) (assert-equal local-node-version (dict-ref upgrade current-version: "")) (assert-equal "/node/api/version" (dict-ref (dict-ref upgrade check: #{}) endpoint: "")) (assert-false (dict-ref (dict-ref upgrade apply: #{}) available: #t)) (assert-false (dict-ref (dict-ref upgrade rollback: #{}) available: #t)))) (list (persistent-status-from-observations #{ platform-kind: "desktop" environment: "native" }) (persistent-status-from-observations #{ platform-kind: "android" environment: "termux" })))) (test "version observations classify upgrade state" (assert-equal "not-checked" (dict-ref (local-node-upgrade-posture "desktop" "native" #f #f) state: "")) (assert-equal "up-to-date" (dict-ref (local-node-upgrade-posture "desktop" "native" #f local-node-version) state: "")) (assert-equal "upgrade-available" (dict-ref (local-node-upgrade-posture "android" "termux" #t "0.2.0") state: ""))) (test "platform install strategy is explicit" (assert-equal "package-managed" (dict-ref (dict-ref (local-node-upgrade-posture "desktop" "native" #f #f) installation: #{}) strategy: "")) (assert-equal "atomic-version-directory" (dict-ref (dict-ref (local-node-upgrade-posture "android" "termux" #t #f) installation: #{}) strategy: ""))))(test-group "persistent app selection" (test "staging alias" (assert-equal "https://next.useslate.org" (persistent-app-url "staging"))) (test "production alias" (assert-equal "https://useslate.org" (persistent-app-url "production"))) (test "arbitrary https URL" (assert-equal "https://preview.example/app" (persistent-app-url "https://preview.example/app"))) (test "plaintext and unknown aliases are refused" (assert-false (persistent-app-url "http://example.com")) (assert-false (persistent-app-url "preview"))))packages/slate-cli/test/test-proxy.sglmodified
(cache-set-version-seen! c "V-9") (cache-put! c "/sw.js" "V-9" (string->utf8 "x") "text/javascript" "no-cache") (let* ((daemon #{ config: config instance: "t" }) (info (version-info daemon))) (info (version-info daemon (dead-fetch)))) (test "reports cli-version" (assert-equal cli-version (dict-ref info cli-version: #f))) (test "reports protocol 1" (assert-equal 1 (dict-ref info protocol: #f))) (test "reports pwa-version-seen from the cache"packages/slate-cli/test/test-reaper.sglmodified
(sleep 1.6) (reap-pass hub) (vector-set! gone 0 (hub-session-count hub)) (sleep 1.0))) (assert-true (not (hub-quiescent? hub))) (sleep 1.0) (assert-true (hub-quiescent? hub)))) (assert-equal 0 (vector-ref gone 0)))) ;; ARM 2 — THE STAND-DOWN CONTROL, and the arm the brief calls most likely;; A harness that supplies the context production is missing cannot see a;; missing-context defect. The only cure is an arm that runs the REAL BINARY on;; the REAL PATH, which is what this is.(define (slate-binary) "./build/dev/bin/slate")(define (slate-binary) (or (getenv "SLATE_TEST_BIN") "./build/dev/bin/slate"))(test-group "reaper - the holder boots (real binary, real entry point)"packages/slate-cli/test/test-resource-plane.sglmodified
(sigil io) (sigil fs) (sigil path) (sigil process) (sigil json) (sigil array) (sigil wire)(grant-add! GRANTS (string-append "fs:rw:" ROOT))(grant-add! GRANTS "exec:full")(define HOME-GRANTS (make-grants principal: (string-append "web:" ORIGIN)))(grant-add! HOME-GRANTS (string-append "fs:ro:" (getenv "HOME")))(define DAEMON #{ config: (make-config machine-name: "testhost" state-dir: "/tmp") instance: "test-instance" }) #{ config: (make-config machine-name: "testhost" state-dir: ROOT) instance: "test-instance" node-identity: (vector "slate-node-test-opaque") node-label: (vector "testbox") });; A dedicated listing dir: sub/ (dir), a.txt, b.txt.(define LIST (path-join ROOT "listing"));; ============================================================;; fs/read-dir — bulk listing round-trip + canonical order;; ============================================================(test-group "fs/home - root landing" (test "returns the process home when that tree is granted" (let ((rep (send #{ t: "req" id: 6 verb: "fs/home" args: #{} } HOME-GRANTS))) (assert-equal "ok" (rt rep)) (assert-equal (getenv "HOME") (dict-ref (rresult rep) path: #f)))) (test "does not disclose home without a filesystem grant" (let* ((nogrants (make-grants principal: "web:evil")) (rep (send #{ t: "req" id: 61 verb: "fs/home" args: #{} } nogrants))) (assert-equal "err" (rt rep)) (assert-equal "grant-denied" (rcode rep)))))(test-group "fs/read-dir - bulk listing" (test "real dir -> bulk frame the client decodes, canonical order" (let* ((rep (send #{ t: "req" id: 7 verb: "fs/read-dir" args: #{ path: LIST } } (assert-true (string? (dict-ref r platform: #f))) (assert-true (string? (dict-ref r hostname: #f))) (assert-equal "testhost" (dict-ref r hostname: #f)) (assert-equal "slate-node-test-opaque" (dict-ref r node-identity: #f)) (assert-equal "testbox" (dict-ref r node-label: #f)) (assert-false (dict-ref (dict-ref r direct: #{}) available: #t)) (assert-true (array? (dict-ref r capabilities: #f))) (assert-true (string-contains? (call-with-output-string (lambda (port) (write (dict-ref r capabilities: #[]) port))) "session")) (assert-true (string-contains? (call-with-output-string (lambda (port) (write (dict-ref r capabilities: #[]) port))) "agent")) (assert-equal '() (dict-ref r pairings: #f)) (assert-equal "stale" (dict-ref r pairing-revocation-freshness: #f)) ;; grant summary = the origin's configured spec list (non-empty). (assert-true (pair? (dict-ref r grants: #f)))))) (assert-true (bytevector? body)) (assert-equal "bee" (utf8->string body))))))(test-group "fs/read-file and fs/write-file - streamed transfer negotiation" (test "read negotiation authorizes and reports size without a unary body" (let* ((p (path-join ROOT "stream-read.bin")) (_ (write-file-bytes p (bytevector 1 2 3 4 5 6))) (rep (send #{ t: "req" id: 70 verb: "fs/read-file" args: #{ path: p transfer: #t } } GRANTS)) (transfer (reply-transfer rep))) (assert-equal 'download (dict-ref transfer direction: #f)) (assert-equal p (dict-ref transfer path: #f)) (assert-equal 6 (dict-ref transfer size: #f)) (assert-false (reply-bulk rep)))) (test "write negotiation checks grants and pins the declared size" (let* ((p (path-join ROOT "stream-write.bin")) (rep (send #{ t: "req" id: 71 verb: "fs/write-file" args: #{ path: p transfer: #t size: 9000000 } } GRANTS)) (transfer (reply-transfer rep))) (assert-equal 'upload (dict-ref transfer direction: #f)) (assert-equal 9000000 (dict-ref transfer size: #f)) (assert-false (file-exists? p)))) (test "invalid size and denied paths open no transfer descriptor" (let ((bad-size (send #{ t: "req" id: 72 verb: "fs/write-file" args: #{ path: (path-join ROOT "bad.bin") transfer: #t size: -1 } } GRANTS)) (denied (send #{ t: "req" id: 73 verb: "fs/write-file" args: #{ path: "/etc/slate-transfer-denied" transfer: #t size: 1 } } (make-grants principal: "web:none")))) (assert-equal "invalid-args" (rcode bad-size)) (assert-equal "grant-denied" (rcode denied)) (assert-false (reply-transfer bad-size)) (assert-false (reply-transfer denied)))) )(test-group "fs/write-file - inline string" (test "writes and returns the new stat" (let* ((p (path-join ROOT "written.txt")) (test "unknown verb -> unknown-verb err" (let ((rep (send #{ t: "req" id: 50 verb: "fs/bogus" args: #{} } GRANTS))) (assert-equal "unknown-verb" (rcode rep)))) (test "local-node role refuses an undeclared remote transport family" (let ((rep (send #{ t: "req" id: 54 verb: "remote/dial" } GRANTS))) (assert-equal "err" (rt rep)) (assert-equal "role-denied" (rcode rep)))) (test "missing id -> invalid-id err" (let ((rep (dispatch-req DAEMON ORIGIN GRANTS #{ t: "req" verb: "sys/info" })))packages/slate-cli/test/test-revoke.sglmodified
(_ (mint! store useslate))) (test "paired origin -> #t" (assert-true (origin-still-paired? store useslate))) (test "unpaired origin -> #f" (assert-false (origin-still-paired? store other)))))(test-group "metadata-driven connection invalidation" (test "matched paired connections are removed before close" (let* ((reg (make-conn-registry)) (closed '()) (paired (registry-add! reg "direct" (lambda () (set! closed (cons 'paired closed))) #{ auth-kind: 'paired peer: "phone" })) (_broker (registry-add! reg "broker" (lambda () (set! closed (cons 'broker closed))) #{ auth-kind: 'broker peer: "phone" }))) (assert-equal "phone" (dict-ref (entry-metadata paired) peer: #f)) (assert-equal 1 (registry-close-matching! reg (lambda (entry) (eq? (dict-ref (entry-metadata entry) auth-kind: #f) 'paired)))) (assert-equal '(paired) closed) (assert-equal 1 (length (registry-entries reg))))))packages/slate-cli/test/test-sessions.sglmodified
(sigil math) (sigil string) (sigil io) (sigil process) (sigil async) (sigil channels) (sigil websocket frame)(define (marker? cap sid s) (string-contains? (utf8->string (collected-out cap sid)) s))(define (trace-result cap sid seq) (let loop ((frames (cap-frames cap))) (cond ((null? frames) #f) ((= (ws-frame-opcode (car frames)) opcode-binary) (let ((frame (decode-stream-frame (ws-frame-payload (car frames))))) (if (and (stream-frame? frame) (= (stream-frame-type frame) frame-trace-out) (= (stream-frame-id frame) sid) (= (bytevector-length (stream-frame-payload frame)) 12) (= (u32-at (stream-frame-payload frame) 0) seq)) frame (loop (cdr frames))))) (else (loop (cdr frames))))))(define (error-text cap sid) (let loop ((frames (cap-frames cap))) (cond(define (send-frame! daemon conn type sid payload) (sessions-handle-binary daemon conn (encode-stream-frame type sid payload)))(test-group "sessions - late client frames fail cleanly" (test "credit racing session exit returns an encoded error"(test-group "sessions - late client frames distinguish acknowledgements from operations" (test "credit racing session exit is an idempotent acknowledgement" (let ((daemon (make-test-daemon)) (conn (make-fake-conn)) (cap (make-cap))) (send-frame! daemon conn frame-credit 99 (u32-be 10)) (drain! conn cap) (assert-false (error-text cap 99)))) (test "input targeting a retired session still returns an encoded error" (let ((daemon (make-test-daemon)) (conn (make-fake-conn)) (cap (make-cap))) (send-frame! daemon conn frame-data-in 99 (string->utf8 "whoami\n")) (drain! conn cap) (assert-equal "no such session" (error-text cap 99)))))(test-group "sessions - opt-in terminal performance trace" (test "trace marker correlates the next terminal write and output" (let ((seen (vector #f))) (with-async (let ((daemon (make-test-daemon)) (conn (make-fake-conn)) (cap (make-cap))) (sessions-on-text daemon conn (open-req #[ "sh" "-c" "cat" ])) (pump-until conn cap (lambda () (> (out-length cap 1) 0)) 250) (send-frame! daemon conn frame-trace-in 1 (u32-be 4242)) (send-frame! daemon conn frame-data-in 1 (string->utf8 "TRACE-")) ;; Two writes may be coalesced into one PTY output chunk; both trace ;; markers must be retired by that chunk rather than drifting. (send-frame! daemon conn frame-trace-in 1 (u32-be 4243)) (send-frame! daemon conn frame-data-in 1 (string->utf8 "OK\n")) (pump-until conn cap (lambda () (and (marker? cap 1 "TRACE-OK") (trace-result cap 1 4242) (trace-result cap 1 4243))) 250) (let ((frame (trace-result cap 1 4242))) (vector-set! seen 0 (and frame (trace-result cap 1 4243) (>= (u32-at (stream-frame-payload frame) 4) 0) (>= (u32-at (stream-frame-payload frame) 8) 0)))) (teardown! daemon conn 1))) (assert-true (vector-ref seen 0)))));; Wake (credit) + close a session and let it reap, so no goroutine;; lingers past the with-async block.(define (teardown! daemon conn sid);; 4. On-the-wire: session/open runs a real pty;; ============================================================(test-group "sessions - open + stream (real pty)" (test "session/open honors grants cached by an authenticated direct peer" (let ((ok (vector #f))) (with-async (let ((daemon (make-test-daemon)) (conn (make-fake-conn)) (cap (make-cap))) ;; This peer is intentionally absent from daemon config. Direct ;; mutual authentication supplies the effective grants on CONN. (conn-state-set! conn "test-origin" "gate-caller") (conn-state-set! conn "grants" (build-grants "web:gate-caller" (list "pty:on" "exec:full"))) (sessions-on-text daemon conn (open-req #[ "sh" "-c" "printf DIRECT-GRANTS-OK" ])) (pump-until conn cap (lambda () (marker? cap 1 "DIRECT-GRANTS-OK")) 250) (vector-set! ok 0 (marker? cap 1 "DIRECT-GRANTS-OK")))) (assert-true (vector-ref ok 0)))) (test "session/open runs a shell and streams its output as data-out" (let ((ok (vector #f))) (with-async (teardown! daemon conn 1))) (assert-true (string-contains? (vector-ref seen 0) "ARGV-HONOURED")))) (test "session/open starts the child in the requested working directory" (let ((seen (vector "")) (home (getenv "HOME"))) (with-async (let ((daemon (make-test-daemon)) (conn (make-fake-conn)) (cap (make-cap))) (sessions-on-text daemon conn (encode-control #{ t: "req" id: 1 verb: "session/open" args: #{ argv: #[ "sh" "-c" "pwd" ] kind: "pty" cwd: home cols: 80 rows: 24 } })) (pump-until conn cap (lambda () (marker? cap 1 home)) 250) (vector-set! seen 0 (utf8->string (collected-out cap 1))) (teardown! daemon conn 1))) (assert-true (string-contains? (vector-ref seen 0) home)))) ;; NAMED FOR WHAT IT ACTUALLY EXERCISES. It sends `kind: "pty"` EXPLICITLY, so ;; it is about an honoured explicit kind — its first version used `open-req`, ;; which sends no `kind` field at all, and therefore only ever exercised (or sid (loop (cdr fs))))) (else (loop (cdr fs))))))(define (ok-result-for cap id) (let loop ((fs (cap-frames cap))) (cond ((null? fs) #f) ((= (ws-frame-opcode (car fs)) opcode-text) (let ((v (decode-control (utf8->string (ws-frame-payload (car fs)))))) (if (and v (string=? (dict-ref v t: "") "ok") (= (dict-ref v id: -1) id)) (dict-ref v result: #{}) (loop (cdr fs))))) (else (loop (cdr fs))))))(test-group "sessions - detach keeps alive, attach replays" (test "a new connection attaching to a detached session replays the ring" (let ((got (vector #f)) (minted (vector #f))) args: #{ sid: (vector-ref minted 0) } })) (pump-until c2 cap2 (lambda () (marker? cap2 1 "MARK-REPLAY")) 150) (vector-set! got 0 (marker? cap2 1 "MARK-REPLAY")) ;; The count is part of the attach ack, before the ordered replay ;; frame. A client can therefore reseed its retained emulator ;; without guessing whether the next output is replay or live. (let ((result (ok-result-for cap2 3))) (assert-true (dict? result)) (assert-true (> (dict-ref result replay-bytes: 0) 0))) (teardown! daemon c2 1)))) ;; Assert the sid's grammar too: if session/open ever stopped reporting a ;; durable sid, `ok-sid` would yield #f, both verbs would fail on apackages/slate-cli/test/test-terminal-discovery.sgladded
(import (sigil test) (sigil array) (sigil time) (sigil io) (slate-cli sessions))(define (fixture-session sid principal command) (vector 'hs sid principal 'pty #f #f #f 0 #f (current-second) #f 259200 0 1 '() #{ command: command cwd: "/work" created: 123 }))(test "terminal catalog respects the authenticated principal and reports identity" (let* ((hub (make-session-hub)) (mine (fixture-session "s-1111111111111111" "web:phone" "bash")) (other (fixture-session "s-2222222222222222" "web:other" "zsh"))) (vector-set! (vector-ref hub 2) 0 (list mine other)) (let ((rows (hub-session-summaries hub "web:phone"))) (assert-equal 1 (length rows)) (assert-equal "s-1111111111111111" (dict-ref (car rows) sid: #f)) (assert-equal "bash" (dict-ref (car rows) command: #f)) (assert-equal "/work" (dict-ref (car rows) cwd: #f)) (assert-equal 123 (dict-ref (car rows) created: #f)) (assert-true (not (dict-ref (car rows) attached: #t)))) (assert-equal '() (hub-session-summaries hub "web:stranger")) (vector-set! mine 6 'connection) (assert-true (dict-ref (car (hub-session-summaries hub "web:phone")) attached: #f)) (vector-set! mine 10 #t) (assert-true (not (dict-ref (car (hub-session-summaries hub "web:phone")) alive: #t)))))(import (sigil async) (sigil channels) (sigil fs) (sigil path) (sigil process) (sigil websocket frame) (slate-cli config) (slate-cli listen) (slate-cli wire) (slate-cli enclave-stream) (slate-cli terminal discovery))(define (catalog-conn origin) (let ((conn (make-conn-ctx #f #f))) (conn-state-set! conn "test-origin" origin) (conn-state-set! conn "authed?" #t) (conn-state-set! conn "outq" (make-channel 256)) conn))(define (catalog-request daemon conn id verb args) (sessions-on-text daemon conn (encode-control #{ t: "req" id: id verb: verb args: args })) (let loop () (let ((item (channel-try-receive (conn-state-get conn "outq" #f)))) (and (pair? item) (let ((frame (frame-decode-result-frame (decode-frame (car item))))) (if (and frame (= (ws-frame-opcode frame) opcode-text)) (let ((reply (decode-control (utf8->string (ws-frame-payload frame))))) (if (equal? (dict-ref reply id: #f) id) reply (loop))) (loop)))))))(test "holder LIST answers with principal-filtered summaries over its real socket" (let* ((dir (string-append "/tmp/slate-catalog-test-" (number->string (process-id)))) (path (path-join dir "h-test.sock")) (hub (make-session-hub)) (running (vector #t))) (ensure-directory dir) (vector-set! (vector-ref hub 2) 0 (list (fixture-session "s-3333333333333333" "web:phone" "bash") (fixture-session "s-4444444444444444" "web:other" "zsh"))) (with-async (assert-true (enclave-stream-listen! path (make-config) hub)) (go (let poll () (when (vector-ref running 0) (enclave-stream-poll!) (sleep 0.005) (poll)))) (let ((rows (terminal-query path "web:phone"))) (assert-equal 1 (length rows)) (assert-equal "s-3333333333333333" (dict-ref (car rows) sid: #f))) (assert-equal '() (terminal-query path "web:unknown")) (let ((result (terminal-discover dir #f "web:phone" '()))) (assert-equal 0 (dict-ref result unavailable: -1)) (assert-equal 1 (array-length (dict-ref result sessions: #[])))) (let* ((local (make-session-hub)) (conn (catalog-conn "phone")) (daemon #{ sessions: local config: (make-config) session-directory: (vector (lambda (principal mine) (terminal-discover dir #f principal mine))) })) (vector-set! (vector-ref local 2) 0 (list (fixture-session "s-5555555555555555" "web:phone" "sh"))) (let* ((reply (catalog-request daemon conn 12 "session/list" #{ principal: "web:other" })) (result (dict-ref reply result: #{})) (rows (array->list (dict-ref result sessions: #[])))) (assert-equal 1 (dict-ref result catalog-version: #f)) (assert-equal 2 (length rows)) (assert-equal "s-5555555555555555" (dict-ref (car rows) sid: #f)) (assert-equal "s-3333333333333333" (dict-ref (cadr rows) sid: #f)) (assert-equal "relay" (dict-ref (cadr rows) route: #f)))) (vector-set! running 0 #f) (enclave-stream-unlisten!) (sleep 0.02))))(test "listing and reattaching a real PTY preserves its process and session identity" (with-async (let* ((hub (make-session-hub)) (daemon #{ sessions: hub config: (make-config grants: (list (cons "https://useslate.org" (list "exec:full" "pty:on" "fs:rw:/tmp")))) }) (first (catalog-conn "https://useslate.org")) (second (catalog-conn "https://useslate.org")) (foreign (catalog-conn "https://other.example")) (opened (catalog-request daemon first 1 "session/open" #{kind: "pty" argv: (list "sh" "-c" "while read line; do echo alive; done") cwd: "/tmp" cols: 80 rows: 24})) (binding (dict-ref opened result: #{})) (sid (dict-ref binding sid: #f)) (chan (dict-ref binding chan: #f))) (assert-equal "ok" (dict-ref opened t: #f)) (let* ((hs (car (vector-ref (vector-ref hub 2) 0))) ;; Inspect the actual process handle, independently of the wire SID. (process (vector-ref (vector-ref hs 4) 3)) (pid (process-pid process))) (catalog-request daemon first 2 "session/detach" #{sid: sid}) (let* ((listed (catalog-request daemon second 3 "session/list" #{})) (rows (array->list (dict-ref (dict-ref listed result: #{}) sessions: #[])))) (assert-equal 1 (length rows)) (assert-equal sid (dict-ref (car rows) sid: #f)) (assert-equal "/tmp" (dict-ref (car rows) cwd: #f)) (assert-true (not (dict-ref (car rows) attached: #t)))) (assert-equal "err" (dict-ref (catalog-request daemon foreign 4 "session/attach" #{sid: sid}) t: #f)) (assert-equal "ok" (dict-ref (catalog-request daemon second 5 "session/attach" #{sid: sid}) t: #f)) (assert-equal 1 (hub-session-count hub)) (assert-true (process-alive? process)) (assert-equal pid (process-pid (vector-ref (vector-ref (car (vector-ref (vector-ref hub 2) 0)) 4) 3))) (sessions-handle-binary daemon second (encode-stream-frame frame-credit chan (u32-be 1000000))) (sessions-handle-binary daemon second (encode-stream-frame frame-close chan (bytevector))) (sleep 0.3)))))(test-summary)packages/slate-cli/test/test-terminal-owner.sgladded
(import (sigil test) (sigil async) (sigil channels) (sigil fs) (sigil io) (sigil system grant) (sigil websocket frame) (slate-cli config) (slate-cli listen) (slate-cli sessions) (slate-cli terminal owner) (slate-cli wire))(test "delegated opens enforce the connection's actual exec, PTY and cwd grants" (with-async (let* ((conn (make-virtual-conn (lambda (bytes) #t) "phone")) (daemon #{config: (make-config grants: (list (cons "phone" (list "exec:full" "pty:on" "fs:rw:/")) ))}) (restricted (make-grants principal: "web:phone"))) (grant-add! restricted "exec:allowlist:sh") (conn-state-set! conn "grants" restricted) ;; The permissive config must never override an authenticated connection. (assert-error (sessions-authorize-open! daemon conn #{argv: (list "sh")})) (grant-add! restricted "pty:on") (sessions-authorize-open! daemon conn #{argv: (list "sh")}) (assert-error (sessions-authorize-open! daemon conn #{argv: (list "bash")})) (assert-error (sessions-authorize-open! daemon conn #{argv: (list "sh") cwd: "/tmp"})) (grant-add! restricted "fs:ro:/tmp") (sessions-authorize-open! daemon conn #{argv: (list "sh") cwd: "/tmp"}) ;; Replacing grants after a previous successful open must take effect. (conn-state-set! conn "grants" (make-grants principal: "web:phone")) (assert-error (sessions-authorize-open! daemon conn #{argv: (list "sh")})))))(test "denied delegated opens answer the caller before starting any owner" (with-async (call-with-temp-directory (lambda (dir) (let* ((replies '()) (conn (make-virtual-conn (lambda (bytes) (set! replies (cons bytes replies))) "phone")) (config (make-config state-dir: dir)) (daemon #{config: config sessions: (make-session-hub) session-backend: (make-terminal-backend config)})) (conn-state-set! conn "grants" (make-grants principal: "web:phone")) (sessions-on-text daemon conn (encode-control #{t: "req" id: 1 verb: "session/open" args: #{argv: (list "sh")}})) (assert-equal 1 (length replies)) (let* ((frame (frame-decode-result-frame (decode-frame (car replies)))) (reply (decode-control (utf8->string (ws-frame-payload frame))))) (assert-equal "err" (dict-ref reply t: #f))) (assert-true (not (directory? (string-append dir "/pty")))))) recursive: #t)))(test "the owner refuses excess sessions and releases capacity after real cleanup" (with-async (let* ((replies '()) (conn (make-virtual-conn (lambda (bytes) (let ((frame (frame-decode-result-frame (decode-frame bytes)))) (when (= (ws-frame-opcode frame) opcode-text) (set! replies (cons (decode-control (utf8->string (ws-frame-payload frame))) replies))))) "phone")) (hub (make-session-hub)) (daemon #{config: (make-config) sessions: hub max-sessions: 1})) (define (open! id) (sessions-on-text daemon conn (encode-control #{t: "req" id: id verb: "session/open" args: #{argv: (list "sh" "-c" "read line")}})) (let loop ((tries 100)) (let ((reply (find (lambda (item) (equal? id (dict-ref item id: #f))) replies))) (cond (reply reply) ((= tries 0) (error "session reply timed out")) (else (sleep 0.01) (loop (- tries 1))))))) (define (close! reply) (sessions-handle-binary daemon conn (encode-stream-frame frame-close (dict-ref (dict-ref reply result: #{}) chan: 0) (bytevector))) (let loop ((tries 200)) (unless (or (hub-quiescent? hub) (= tries 0)) (sleep 0.01) (loop (- tries 1)))) (assert-true (hub-quiescent? hub))) (conn-state-set! conn "grants" (grant-allow-all principal: "web:phone")) (let ((first (open! 1))) (assert-equal "ok" (dict-ref first t: #f)) (assert-equal "busy" (dict-ref (open! 2) code: #f)) (assert-equal 1 (hub-session-count hub)) (close! first) (let ((replacement (open! 3))) (assert-equal "ok" (dict-ref replacement t: #f)) (close! replacement))) (sessions-conn-closed! daemon conn))))packages/slate-cli/test/test-transfer-file.sgladded
(import (sigil test) (sigil core) (sigil fs) (sigil path) (sigil system grant) (slate-cli resource transfer) (slate-cli resource transfer file) (slate-cli wire))(define ROOT (make-temp-directory))(define GRANTS (make-grants principal: "web:test"))(grant-add! GRANTS (string-append "fs:rw:" ROOT))(define (frame-list-bytes frames) (let loop ((rest frames) (out (make-bytevector 0 0))) (if (null? rest) out (let* ((frame (decode-stream-frame (car rest))) (batch (decode-transfer-batch frame))) (if batch (loop (cdr rest) (bytevector-append out (dict-ref batch bytes: #f))) (loop (cdr rest) out))))))(test-group "streaming file transfer adapter" (test "upload is invisible until exact end then publishes atomically" (let* ((m (make-file-transfer-manager)) (path (path-join ROOT "upload.bin")) (body (bytevector 1 2 3 4 5))) (write-file-string path "old") (assert-equal 'ready (dict-ref (file-upload-open! m GRANTS 1 path 5) status: #f)) (assert-equal 'accepted (dict-ref (file-upload-batch! m (decode-stream-frame (encode-transfer-batch 1 0 body))) status: #f)) (assert-equal "old" (read-file-string path)) (assert-equal 'complete (dict-ref (file-transfer-end! m (decode-stream-frame (encode-transfer-end 1 1 5))) status: #f)) (let ((actual (read-file-bytes path))) (assert-equal 5 (bytevector-length actual)) (assert-equal 1 (bytevector-u8-ref actual 0)) (assert-equal 5 (bytevector-u8-ref actual 4))) (assert-equal 0 (file-transfer-count m)))) (test "cancel and connection close preserve destination and remove state" (let* ((m (make-file-transfer-manager)) (a (path-join ROOT "cancel-a.bin")) (b (path-join ROOT "cancel-b.bin"))) (write-file-string a "before-a") (write-file-string b "before-b") (assert-equal 'ready (dict-ref (file-upload-open! m GRANTS 2 a 3) status: #f)) (assert-equal 'ready (dict-ref (file-upload-open! m GRANTS 3 b 3) status: #f)) (assert-equal 2 (file-transfer-count m)) (file-upload-batch! m (decode-stream-frame (encode-transfer-batch 2 0 (bytevector 1)))) (assert-equal 'cancelled (dict-ref (file-transfer-cancel! m 2 'user) status: #f)) (assert-equal 1 (file-transfer-count m)) (assert-equal 1 (file-transfer-close-all! m 'connection-lost)) (assert-equal "before-a" (read-file-string a)) (assert-equal "before-b" (read-file-string b)) (assert-equal 0 (file-transfer-count m)))) (test "download obeys credit and emits an exact end" (let* ((m (make-file-transfer-manager)) (path (path-join ROOT "download.bin")) (body (make-bytevector (+ transfer-chunk-bytes 7) 42))) (write-file-bytes path body) (assert-equal 'ready (dict-ref (file-download-open! m GRANTS 4 path) status: #f)) (let ((first (file-download-credit! m (decode-stream-frame (encode-transfer-credit 4 transfer-chunk-bytes))))) (assert-equal 'active (dict-ref first status: #f)) (assert-equal transfer-chunk-bytes (dict-ref first progress: #f)) (assert-equal transfer-chunk-bytes (bytevector-length (frame-list-bytes (dict-ref first frames: '()))))) (let ((last (file-download-credit! m (decode-stream-frame (encode-transfer-credit 4 7))))) (assert-equal 'complete (dict-ref last status: #f)) (assert-equal 2 (length (dict-ref last frames: '()))) (assert-equal 7 (bytevector-length (frame-list-bytes (dict-ref last frames: '()))))) (assert-equal 0 (file-transfer-count m)))) (test "grant denial opens no transfer" (let ((m (make-file-transfer-manager)) (none (make-grants principal: "web:none"))) (assert-equal 'refused (dict-ref (file-upload-open! m none 5 (path-join ROOT "denied.bin") 1) status: #f)) (assert-equal 0 (file-transfer-count m)))) )packages/slate-cli/test/test-transfer.sgladded
(import (sigil test) (sigil core) (slate-cli resource transfer) (slate-cli wire))(test-group "multi-frame transfer state" (test "ordered batches spend credit and complete at the exact boundary" (let ((r (make-transfer-registry))) (transfer-open! r 7 'receive 5) (assert-equal 5 (dict-ref (transfer-grant! r 7 5) credit: #f)) (assert-equal 'accepted (dict-ref (transfer-batch! r 7 0 (bytevector 1 2 3)) status: #f)) (assert-equal 2 (dict-ref (transfer-ref r 7) credit: #f)) (assert-equal 'accepted (dict-ref (transfer-batch! r 7 1 (bytevector 4 5)) status: #f)) (assert-equal 'complete (dict-ref (transfer-end! r 7 2 5) status: #f)))) (test "out-of-order, over-credit, and size overruns fail closed" (let ((r (make-transfer-registry))) (transfer-open! r 8 'receive 3) (transfer-grant! r 8 2) (assert-equal 'out-of-order (dict-ref (transfer-batch! r 8 1 (bytevector 1)) reason: #f)) (assert-equal 'credit-exhausted (dict-ref (transfer-batch! r 8 0 (bytevector 1 2 3)) reason: #f)) (transfer-grant! r 8 2) (assert-equal 'size-overrun (dict-ref (transfer-batch! r 8 0 (bytevector 1 2 3 4)) reason: #f)) ;; Refusals do not advance or spend the transfer. (assert-equal 0 (dict-ref (transfer-ref r 8) next-seq: #f)) (assert-equal 4 (dict-ref (transfer-ref r 8) credit: #f)))) (test "end detects truncation and cancellation releases the id" (let ((r (make-transfer-registry))) (transfer-open! r 9 'receive 4 #{ temporary: "x.tmp" }) (transfer-grant! r 9 4) (transfer-batch! r 9 0 (bytevector 1 2)) (assert-equal 'size-mismatch (dict-ref (transfer-end! r 9 1 2) reason: #f)) (let ((cancelled (transfer-cancel! r 9 'connection-lost))) (assert-equal 'cancelled (dict-ref cancelled status: #f)) (assert-equal "x.tmp" (dict-ref (dict-ref (dict-ref cancelled entry: #{}) metadata: #{}) temporary: #f))) (assert-equal 0 (transfer-count r)) (assert-false (transfer-ref r 9)))) (test "ids cannot be replayed while active and credits cannot wrap" (let ((r (make-transfer-registry))) (transfer-open! r 10 'send #f) (assert-equal 'duplicate-id (dict-ref (transfer-open! r 10 'receive #f) reason: #f)) (transfer-grant! r 10 4294967295) (assert-equal 'credit-overflow (dict-ref (transfer-grant! r 10 1) reason: #f)))) (test "transfer frame codecs preserve sequence, totals, and raw bytes" (let* ((batch-frame (decode-stream-frame (encode-transfer-batch 11 3 (bytevector 0 255 7)))) (batch (decode-transfer-batch batch-frame)) (end (decode-transfer-end (decode-stream-frame (encode-transfer-end 11 4 70000)))) (credit (decode-transfer-credit (decode-stream-frame (encode-transfer-credit 11 transfer-initial-credit))))) (assert-equal 11 (dict-ref batch id: #f)) (assert-equal 3 (dict-ref batch seq: #f)) (assert-equal (bytevector 0 255 7) (dict-ref batch bytes: #f)) (assert-equal 4 (dict-ref end next-seq: #f)) (assert-equal 70000 (dict-ref end total: #f)) (assert-equal transfer-initial-credit (dict-ref credit amount: #f)))) (test "malformed transfer payloads are refused by the codec" (assert-false (decode-transfer-batch (decode-stream-frame (encode-stream-frame frame-batch 1 (bytevector 0 0 0))))) (assert-false (decode-transfer-end (decode-stream-frame (encode-stream-frame frame-end 1 (make-bytevector 7 0))))) (assert-false (decode-transfer-credit (decode-stream-frame (encode-stream-frame frame-transfer-credit 1 (make-bytevector 4 0)))))) )packages/slate-netem/package.sglmodified
(package name: "slate-netem" ;; version inherited from the workspace (../../package.sgl) sigil: "0.20.1" sigil: "0.20.3" description: "Slate connectivity emulator — reproducible degraded/broken links" url: "https://codeberg.org/sigil/slate" license: "BSD-3-Clause" bundle-name: "slate-netem" dependencies: (list (from-git url: "codeberg:sigil/sigil" package: "sigil-stdlib" version: "^0.20") ) ;; Keep independent read/write waiters on the same socket until the next Sigil release. (from-git url: "codeberg:sigil/sigil" package: "sigil-stdlib" commit: "9fdbd7aff420e65c8c2c73f0f97abf8dbfae2d95")) dev-dependencies: (list (from-git url: "codeberg:sigil/sigil" package: "sigil-test" version: "^0.20") (from-git url: "codeberg:sigil/sigil" package: "sigil-test-runner" version: "^0.20"))) (from-git url: "codeberg:sigil/sigil" package: "sigil-test" version: ">=0.20") (from-git url: "codeberg:sigil/sigil" package: "sigil-test-runner" version: ">=0.20")))packages/slate-netem/scenarios/node-direct-loss.scnadded
;;; Direct carrier cut while the independent Enclave relay stays healthy.(scenario name: "node-direct-loss" summary: "12s direct setup, 8s hard direct-only cut, 8s recovered" timeline: (list (phase name: "direct-up" ms: 12000 link: (up)) (phase name: "direct-cut" ms: 8000 link: (cut)) (phase name: "recovered" ms: 8000 link: (up))) controls: (list (control name: "the real direct WebSocket crossed the proxy" role: liveness check: (forwarded-bytes phase: "direct-up" at-least: 1)) (control name: "the direct carrier was actually cut" role: fault check: (closes phase: "direct-cut" by: emulator at-least: 1)) (control name: "a bounded direct retry reached the cut" role: fault check: (refusals phase: "direct-cut" at-least: 1)) (control name: "the proxy stopped refusing after recovery" role: fault check: (refusals phase: "recovered" at-most: 0))))packages/slate-netem/scenarios/node-transfer-loss.scnadded
;;; A deliberately slow direct carrier followed by a hard cut. A 5 MiB;;; transfer cannot finish inside the throttled phase, so the fault lands after;;; negotiation and multiple frames rather than racing setup or completion.(scenario name: "node-transfer-loss" summary: "20s throttled carrier transfer, 6s hard cut, 10s recovery" timeline: (list (phase name: "transfer" ms: 20000 link: (up) bandwidth: (up-bps 262144 down-bps 262144)) (phase name: "cut" ms: 6000 link: (cut)) (phase name: "recovered" ms: 10000 link: (up))) controls: (list (control name: "multiple transfer windows crossed the carrier proxy" role: liveness check: (forwarded-bytes phase: "transfer" at-least: 524288)) (control name: "the active transfer carrier was cut" role: fault check: (closes phase: "cut" by: emulator at-least: 1))))packages/slate-netem/sigil.lockmodified
(lock (package name: "sigil-stdlib" url: "codeberg:sigil/sigil" ref: "^0.18" sha: "a256db1dac67574c9c9f82a4c758a51b2c378705" ref: "^0.20.3" sha: "2e452e2f641fcf63be4e88a2748b34791543be8f" package-selector: "sigil-stdlib" version: "0.18.5") (package name: "sigil-socket" url: "codeberg:sigil/sigil-socket" ref: "^0.16.0" sha: "6b9e36eb4d4a944b463e5f6826bd711186ba6710" version: "0.16.0") version: "0.20.3") (package name: "sigil-test" url: "codeberg:sigil/sigil" ref: "^0.18" sha: "a256db1dac67574c9c9f82a4c758a51b2c378705" ref: "^0.20.3" sha: "2e452e2f641fcf63be4e88a2748b34791543be8f" package-selector: "sigil-test" version: "0.18.5") version: "0.20.3") (package name: "sigil-test-runner" url: "codeberg:sigil/sigil" ref: "^0.18" sha: "a256db1dac67574c9c9f82a4c758a51b2c378705" ref: "^0.20.3" sha: "2e452e2f641fcf63be4e88a2748b34791543be8f" package-selector: "sigil-test-runner" version: "0.18.5") (package name: "sigil-ansi" url: "codeberg:sigil/sigil-ansi" ref: "^0.16.0" sha: "1e14b6f9d034ca8ef9f404e2ded316d0780ad7cf" version: "0.16.0") version: "0.20.3") (package name: "sigil-json" url: "codeberg:sigil/sigil-json" ref: "^0.16.0" sha: "55c9e74712b5f79b45d41c65cfe0aef2c8f7e155" version: "0.16.0") (package name: "sigil-version" url: "codeberg:sigil/sigil-version" ref: "^0.16.0" sha: "2d2c9ef9c2694466f65abc14cddf9bfb9cada4a4" version: "0.16.0") (package name: "sigil-crypto" url: "codeberg:sigil/sigil-crypto" ref: "^0.16.0" ref: "^0.16.0" sha: "3d441fb32a216aaf99d0e2732bef5877e91d5d48" version: "0.16.2") (package name: "sigil-git" url: "codeberg:sigil/sigil-git" ref: "^0.16.0" sha: "2d54eb99d3ca0597b3ccd9d4829d312c54721086" version: "0.16.1") (package name: "sigil-lib" url: "codeberg:sigil/sigil" ref: "^0.17" sha: "a256db1dac67574c9c9f82a4c758a51b2c378705" sha: "2e452e2f641fcf63be4e88a2748b34791543be8f" package-selector: "sigil-lib" version: "0.18.5") version: "0.20.3"))packages/slate-netem/slate-netem.sgladded
;;; Source launcher for the connectivity emulator. Keeping this two-line path;;; available also lets integration gates run the exact compiled netem modules;;; when a workspace bundler has selected another member's default entry point.(import (slate-netem main))(main)packages/slate-netem/src/slate-netem/main.sglmodified
;; A setup error exits 3, distinct from every verdict, so a gate ;; can never read "the emulator would not start" as "the fault did ;; not fire". (let ((code (guard (e (#t (let* ((raw-args (cdr (command-line))) ;; Running this library from its tiny source launcher leaves the ;; launcher pathname in command-line. This path is also useful as ;; a faithful fallback when a workspace bundler selects the wrong ;; default entry point. (args (if (and (pair? raw-args) (string-contains? (car raw-args) ".sgl")) (cdr raw-args) raw-args)) (code (guard (e (#t (display "slate-netem: ") (display (if (error-object? e) (error-object-message e) e)) (newline) 3)) (dispatch (cdr (command-line)))))) (dispatch args)))) (exit code)))))packages/slate-shell/package.sglmodified
name: "slate-shell" ;; version inherited from the workspace (../../package.sgl) — member packages ;; do not declare their own version. sigil: "0.20.1" sigil: "0.20.3" description: "Slate — a programmable, extensible editor — as a native desktop app" url: "https://codeberg.org/sigil/slate" license: "BSD-3-Clause" (error "slate-shell before-build: stage-assets.sh failed (exit)" stage-status))))))) dependencies: (list ;; Lantern 0.1.4 still imports the removed (sigil env) compatibility ;; library. Pin the Sigil 0.18 fix until the next Lantern release. ;; Lantern 0.1.4 contains the Sigil compatibility fix previously consumed ;; by raw commit. Keep the released range explicit so release builds never ;; depend on an unpublished revision. (from-git url: "codeberg:sigil/lantern" package: "lantern" commit: "16cf13897cb48e4cc1eb4065a9969dfbf23d73f6") version: "^0.1.4") (from-git url: "codeberg:sigil/lantern" package: "lantern-system" commit: "16cf13897cb48e4cc1eb4065a9969dfbf23d73f6")) version: "^0.1.4")) ;; sigil-test + sigil-test-runner build the harness for test/run-checks.sgl ;; (the slate-client protocol / session / server unit checks). slate-shell has ;; native deps (lantern), so the harness needs these explicitly declared. dev-dependencies: (list (from-git url: "codeberg:sigil/sigil" package: "sigil-test" version: "^0.20") (from-git url: "codeberg:sigil/sigil" package: "sigil-test-runner" version: "^0.20"))) (from-git url: "codeberg:sigil/sigil" package: "sigil-test" version: ">=0.20") (from-git url: "codeberg:sigil/sigil" package: "sigil-test-runner" version: ">=0.20")))packages/slate/assets/folder-browser.mjsadded
// The folder renderer owns row content and the bounded listing window. This// controller owns point markers, so n/p can move them without reconciling every// row or running the editor/terminal post-render passes.export function createFolderBrowser({ evaluate, flushPreview }) { let timer = null; let scheduledToken = ''; const rendered = new WeakMap(); function schedule() { const token = evaluate('(slate-folder-preview-token)') || ''; if (token === scheduledToken) return; if (timer !== null) clearTimeout(timer); timer = null; scheduledToken = token; if (!token) return; timer = setTimeout(() => { timer = null; scheduledToken = ''; // Tokens are decimal generation numbers, minted by the model. The model // also validates the token before starting a request. if (/^\d+$/.test(token)) flushPreview(token); }, 500); } function point(view, index) { const selected = view.querySelector('.folder-row.selected'); const next = view.querySelector(`.folder-row[data-slate-click="folder-select-index:${index}"]`); if (selected !== next) { if (selected) { selected.classList.remove('selected'); selected.setAttribute('aria-selected', 'false'); } if (next) { next.classList.add('selected'); next.setAttribute('aria-selected', 'true'); next.scrollIntoView({ block: 'nearest' }); } } } function motion() { const raw = evaluate('(slate-folder-take-motion)'); if (!raw) return false; const update = JSON.parse(raw); for (const view of document.querySelectorAll('.view.folder')) { if (view.dataset.folderView !== update.name) continue; point(view, update.point); view.querySelector('.folder-preview')?.classList.toggle('pending', update.pending); } schedule(); return true; } function ensure() { for (const view of document.querySelectorAll('.view.folder')) { if (rendered.get(view) !== view.dataset.folderRender) { point(view, Number(view.dataset.folderPoint)); // A full render supplies the current header and waiting/loading/content // text. Only motion between renders needs to mask obsolete contents. view.querySelector('.folder-preview')?.classList.remove('pending'); rendered.set(view, view.dataset.folderRender); } } schedule(); } return { motion, ensure };}packages/slate/assets/index.htmlmodified
<link rel="icon" href="icons/icon-192.png" sizes="192x192" type="image/png"> <link rel="apple-touch-icon" href="icons/icon-192.png"> <script> const SLATE_WEB_CLIENT_VERSION = '2026-09-08-git-terminal-merge-1'; // The node: pairing return page. When the daemon's approval page 303- // redirects the pairing popup to <origin>/pair-return#familiar_code=…&state=…, // this popup lands HERE. Handle it BEFORE booting the heavy wasm app: parse // progressive-enhancement spirit as the LANTERN-BRIDGE feature-detect below. if ('serviceWorker' in navigator && location.protocol.startsWith('http')) { window.addEventListener('load', function () { navigator.serviceWorker.register('sw.js').catch(function (e) { console.warn('[slate] service worker registration failed:', e); function announceWaitingClient(worker) { if (!worker || worker.state === 'redundant') return; fetch('sw.js', { cache: 'no-store' }) .then(function (response) { return response.text(); }) .then(function (source) { var match = source.match(/const VERSION = '([^']+)'/); window.__slateWaitingClientVersion = match ? match[1] : 'available'; if (window.__slateAnnounceClientUpdate) window.__slateAnnounceClientUpdate(); }).catch(function () { window.__slateWaitingClientVersion = 'available'; if (window.__slateAnnounceClientUpdate) window.__slateAnnounceClientUpdate(); }); } function watchClientUpdate(registration) { if (registration.waiting) announceWaitingClient(registration.waiting); registration.addEventListener('updatefound', function () { var installing = registration.installing; if (!installing) return; installing.addEventListener('statechange', function () { // An active registration also proves this is a replacement. The // page can temporarily be uncontrolled after bootstrapping the // manual-update policy, and must still report the waiting update. if (installing.state === 'installed' && (navigator.serviceWorker.controller || registration.active)) { announceWaitingClient(installing); } }); }); } // Never let the browser's HTTP cache satisfy an update check for sw.js. // Slate's assets are versioned by the worker, so accepting a stale worker // here strands the whole application on the old asset cache. navigator.serviceWorker.register('sw.js', { updateViaCache: 'none' }) .then(function (registration) { watchClientUpdate(registration); // Chrome may otherwise throttle its automatic worker checks. Check on // every boot and whenever an installed PWA returns to the foreground. registration.update().catch(function () {}); document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') { registration.update().catch(function () {}); } }); }).catch(function (e) { console.warn('[slate] service worker registration failed:', e); }); // A controller change is acted upon only when the user invoked the // update command. Background registration/update checks may download a // worker, but must never replace or reload the running UI on their own. var reloadingForWorkerUpdate = false; navigator.serviceWorker.addEventListener('controllerchange', function () { if (!window.__slateClientUpdateReloadArmed) return; if (reloadingForWorkerUpdate) return; reloadingForWorkerUpdate = true; window.location.reload(); }); }); } } /* ---- Folder view: a minimal, Emacs-dired-like list. No chrome. ---- */ .view.folder { font-family: var(--font-code); font-size: var(--text-code); } /* A long filename must never scroll the buffer sideways, so the name MIDDLE-truncates and keeps its extension: really-long-name….scm. The extension is what tells you the kind of thing in a file list, so an end-ellipsis (which eats it) is the wrong trade. CSS cannot middle-truncate one string — but it doesn't need to, because the name is rendered as STEM + EXT: the stem is the flex item that shrinks and ellipsises, the ext never shrinks, so the ellipsis lands between them at any pane width. No character budget, so it stays right on a phone too. */ .folder-row { display: flex; align-items: baseline; white-space: pre; cursor: default; overflow: hidden; } .folder-row.selected { background: color-mix(in srgb, var(--accent) 16%, transparent); } .folder-mark { color: transparent; flex: none; width: 2ch; font-weight: 700; } .folder-row.marked .folder-mark { color: var(--accent); } .folder-row.marked:not(.selected) { background: color-mix(in srgb, var(--accent) 7%, transparent); } /* min-width:0 is what lets a flex item shrink below its content width — without it the stem refuses to ellipsis and the row overflows anyway. */ .folder-name { color: var(--fg-1); display: flex; min-width: 0; } .folder-stem { overflow: hidden; text-overflow: ellipsis; white-space: pre; min-width: 0; } .folder-ext { flex: none; white-space: pre; } .folder-row.is-dir .folder-name { color: var(--fg-0); font-weight: 600; } /* flex:none so the size column keeps its width and the NAME gives way instead. */ .folder-size { color: var(--fg-2); margin-left: auto; padding-left: var(--sp-3); flex: none; } .folder-more { color: var(--fg-2); font-style: italic; } /* Folder buffers share the Git views' compact rows and focus language. */ .view.folder { container:folder / inline-size; display:flex; flex-direction:column; min-width:0; min-height:0; padding:0; overflow:hidden; font-family:var(--font-code); font-size:var(--text-code); } .folder-header { flex:none; display:flex; align-items:center; gap:10px; min-height:44px; padding:0 12px; border-bottom:1px solid var(--edge-1); background:var(--bg-1); } .folder-header button, .folder-preview-label button { background:transparent; border:0; color:var(--fg-1); font:inherit; min-width:30px; min-height:34px; cursor:pointer; } .folder-parent { font-size:20px!important; } .folder-path { flex:1; display:flex; min-width:0; overflow:hidden; white-space:nowrap; } .folder-ancestry { overflow:hidden; text-overflow:ellipsis; color:var(--fg-1); } .folder-path strong { flex:none; max-width:100%; overflow:hidden; text-overflow:ellipsis; font-weight:600; } .folder-host { color:var(--fg-2); font-size:.8em; max-width:30%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .folder-workbench { flex:1; min-height:0; display:grid; grid-template-columns:minmax(0,1fr); } .folder.with-preview .folder-workbench { grid-template-columns:minmax(0, 65%) minmax(0,35%); } .folder-directory { display:flex; flex-direction:column; min-width:0; min-height:0; } .folder-listing { flex:1; min-height:0; overflow:auto; } .folder-columns, .folder-row { display:grid; grid-template-columns:28px minmax(0,1fr) 8ch 12ch; align-items:center; column-gap:8px; padding:0 14px 0 0; } .folder.with-git .folder-columns, .folder.with-git .folder-row { grid-template-columns:28px minmax(0,1fr) 3ch 8ch 12ch; } .folder-columns { flex:none; min-height:30px; border-bottom:1px solid var(--edge-0); color:var(--fg-2); font-size:.75em; } .folder-columns>span:nth-last-child(-n+2) { text-align:right; } .folder-row { min-height:34px; border-left:3px solid transparent; cursor:default; } .folder-row:hover { background:var(--bg-1); } .folder-row.selected { background:color-mix(in srgb,var(--accent) 22%,var(--bg-1)); border-left-color:var(--accent); } .folder-row.marked:not(.selected) { background:color-mix(in srgb,var(--accent) 7%,transparent); } .folder-row button { border:0; border-radius:0; background:transparent; font:inherit; cursor:pointer; padding:0; } .folder-mark { color:var(--accent); min-height:30px; width:25px; font-size:.8em!important; } .folder-name { color:var(--fg-0); display:flex; min-width:0; align-items:center; text-align:left; min-height:30px; } .folder-stem { overflow:hidden; text-overflow:ellipsis; white-space:pre; min-width:0; } .folder-ext { flex:none; white-space:pre; color:var(--fg-1); } .folder-row.is-dir .folder-name { font-weight:600; } .folder-row.is-dir .folder-ext { color:var(--accent); } .folder-size, .folder-modified { color:var(--fg-2); text-align:right; font-size:.8em; white-space:nowrap; } .folder-git { display:none; text-align:center; font-size:.8em; } .folder.with-git .folder-git { display:block; } .folder-git.modified { color:var(--accent); } .folder-git.added, .folder-git.untracked { color:var(--ok); } .folder-git.conflict { color:var(--err); } .folder-more { display:block; width:100%; min-height:36px; padding:8px 14px; border:0; background:var(--bg-1); color:var(--accent); text-align:left; font:inherit; font-size:.85em; cursor:pointer; } .folder-more:hover { background:var(--bg-2); } .folder-preview { display:flex; flex-direction:column; min-width:0; min-height:0; overflow:hidden; border-left:1px solid var(--edge-1); background:var(--bg-1); } .folder-preview-label { flex:none; min-height:30px; display:flex; justify-content:space-between; align-items:center; padding:0 14px; border-bottom:1px solid var(--edge-0); color:var(--fg-2); font-size:.75em; text-transform:uppercase; letter-spacing:.1em; } .folder-preview-header { flex:none; padding:18px; } .folder-preview-name { border:0; background:transparent; padding:0; text-align:left; color:var(--fg-0); font:600 1.1em var(--font-code); overflow-wrap:anywhere; cursor:pointer; } .folder-preview-header dl { display:grid; grid-template-columns:9ch minmax(0,1fr); gap:6px; margin:15px 0 0; font-size:.8em; color:var(--fg-1); } .folder-preview-header dt { color:var(--fg-2); } .folder-preview-header dd { margin:0; overflow-wrap:anywhere; } .folder-preview-content { flex:1; min-height:0; overflow:auto; margin:0; padding:16px 18px; border-top:1px solid var(--edge-0); background:var(--bg-0); color:var(--fg-1); font:.85em/1.7 var(--font-code); tab-size:2; white-space:pre; } .folder-preview-image { display:flex; align-items:center; justify-content:center; padding:12px; } .folder-preview-image img { display:block; max-width:100%; max-height:100%; object-fit:contain; } .folder-preview.pending .folder-preview-header, .folder-preview.pending .folder-preview-content { visibility:hidden; pointer-events:none; } .folder-preview.pending { position:relative; } .folder-preview.pending::after { content:"Waiting…"; position:absolute; top:54px; left:18px; color:var(--fg-2); font-size:.85em; } .folder button:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; } .folder.compact .folder-row { min-height:26px; } .folder.compact .folder-row button { min-height:24px; } @container folder (max-width: 760px) { .folder-columns, .folder-row { grid-template-columns:28px minmax(0,1fr) 7ch; } .folder.with-git .folder-columns, .folder.with-git .folder-row { grid-template-columns:28px minmax(0,1fr) 3ch 7ch; } .folder-modified { display:none; } } @container folder (max-width: 600px) { .folder.with-preview .folder-workbench { grid-template-columns:minmax(0,1fr); grid-template-rows:minmax(0,1fr) minmax(0,1fr); } .folder-preview { border-left:0; border-top:1px solid var(--edge-1); } .folder-preview-header { padding:10px 14px; } .folder-preview-header dl { margin-top:8px; } .folder-header { padding:0 8px; gap:6px; } } @media (pointer:coarse) { .folder .folder-row, .folder.compact .folder-row { min-height:44px; } .folder .folder-row button, .folder.compact .folder-row button { min-height:42px; } .folder-more, .folder-header button { min-height:44px; } } /* An empty view centers its hint (see .empty-hint below) rather than pinning "(empty)" to the top-left, so a blank folder/buffer reads as intentional. */ .view:has(> .empty-hint) { display: flex; align-items: center; justify-content: center; } block height: without it the inline line box was taller than the row, and overflow:hidden clipped the top ~1px of an unfocused pane's box cursor (its inset 1px border). Now the cursor cell fills the row exactly. */ .term-content .tr { white-space: pre; height: var(--cell-h, 1.3em); line-height: var(--cell-h, 1.3em); overflow: hidden; } .term-content .tr { position: relative; white-space: pre; height: var(--cell-h, 1.3em); line-height: var(--cell-h, 1.3em); overflow: hidden; } .term-content .tr br { line-height: inherit; } /* R4 prediction is deliberately a disposable overlay inside a row, never emulator state. Once an unconfirmed claim ages past 250ms it becomes visibly provisional; reconciliation can remove this node wholesale. */ .term-prediction { position: absolute; top: 0; white-space: pre; pointer-events: none; } .term-prediction.stale { opacity: 0.58; text-decoration: underline; } .tr:has(.term-prediction) .term-cursor { visibility: hidden; } .term-prediction::after { content: '\00a0'; display: inline-block; width: var(--cell-w, 8px); box-shadow: inset 0 -2px currentColor; } /* One cell, enforced. HTML text layout lets a glyph's natural advance win, so any char the primary font lacks falls back to a font with a different advance and the row drifts (a 40-`♦` row ran 1.5x long; the Claude Code -webkit-tap-highlight-color: transparent; touch-action: manipulation; } .acc-btn:active { background: var(--bg-3); transform: translateY(1px); } /* Global literal key deck. It follows the Ledge in the frame, so the two rows sit below app chrome and immediately above the system keyboard. */ .accessory-key-deck { display: none; flex: 0 0 auto; background: var(--key-deck-bg); touch-action: pan-y; } @media (max-width: 767px) { .accessory-key-deck { display: flex; flex-direction: column; gap: 0; padding: 0 0 env(safe-area-inset-bottom); border-top: 1px solid var(--edge-0); } } .terminal-key-row { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 0; } .terminal-key-page, .terminal-compose-page { display: flex; min-width: 0; height: 64px; position: relative; } .terminal-key-rows { flex: 1; min-width: 0; } .terminal-key-page[hidden], .terminal-compose-page[hidden] { display: none; } .terminal-compose-back, .terminal-compose-send { flex: 0 0 auto; min-width: 36px; padding: 0 8px; border: 1px solid var(--edge-0); border-radius: 0; background: var(--key-deck-bg); color: var(--fg-0); font: 600 var(--text-xs)/1 var(--font-code); touch-action: pan-y; } .terminal-compose-input { flex: 1; min-width: 0; height: 56px; margin: 4px; padding: 6px 8px; resize: none; box-sizing: border-box; border: 1px solid var(--edge-0); border-radius: 4px; background: var(--bg-1); color: var(--fg-0); font: 16px/1.35 var(--font-code); touch-action: auto; } .terminal-compose-input:focus { outline: 1px solid var(--accent); } .terminal-compose-status:not(:empty) { position: absolute; bottom: 100%; left: 0; right: 0; padding: 8px; background: var(--bg-1); color: var(--fg-0); font-size: var(--text-sm); border: 1px solid var(--edge-0); } .terminal-compose-send.history { font-size: 24px; } .terminal-compose-send:disabled { opacity: .4; } .terminal-compose-history { position: absolute; z-index: 15; bottom: 100%; left: 0; right: 0; max-height: min(40vh, 280px); overflow-y: auto; padding: 6px; background: var(--bg-1); border: 1px solid var(--edge-0); box-shadow: 0 -3px 12px #0002; } .terminal-compose-history[hidden] { display: none; } .terminal-history-title { padding: 6px; color: var(--fg-1); font-size: var(--text-xs); } .terminal-compose-history button { display: block; width: 100%; padding: 10px; text-align: left; white-space: pre-wrap; overflow-wrap: anywhere; background: var(--bg-1); color: var(--fg-0); border: 0; border-top: 1px solid var(--edge-0); font: 14px/1.4 var(--font-code); } .terminal-copy-tools { flex-wrap: wrap; } .terminal-copy-tools [hidden], .terminal-copy-header-tools [hidden], .terminal-copy-label[hidden] { display: none; } .terminal-copy-tools button:disabled, .terminal-copy-header-tools button:disabled { opacity: .4; } .terminal-copy-tools [aria-pressed="true"], .terminal-copy-header-tools [aria-pressed="true"] { color: var(--accent); border-color: var(--accent); } .terminal-copy-label { color: var(--accent); font-size: var(--text-xs); white-space: nowrap; } .acc-host-status:empty { display: none; } .acc-cats .terminal-copy-status { display: none; } .terminal-copy-status:not(:empty) { flex-basis: 100%; font-size: var(--text-xs); color: var(--fg-1); } .terminal-copy-header-tools { display: inline-flex; gap: 4px; align-items: center; } .terminal-copy-header-tools button { padding: 0 4px; font: inherit; color: var(--fg-0); background: var(--bg-1); border: 1px solid var(--edge-0); } .terminal-copy-header-tools .terminal-copy-status { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .acc-chip.copy-received::after { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); } @media (max-width: 767px) { .terminal-copy-header-tools { display: none; } } .term-content.terminal-copy-active { user-select: none; -webkit-user-select: none; touch-action: none; } .terminal-copy-region { position: absolute; top: 0; bottom: 0; background: var(--accent); opacity: .28; pointer-events: none; } .terminal-copy-point { position: absolute; top: 0; bottom: 0; border-left: 2px solid var(--accent); pointer-events: none; } .term-key { min-width: 0; height: 32px; display: flex; align-items: center; justify-content: center; background: var(--key-deck-bg); color: var(--fg-0); border: 1px solid var(--edge-0); border-radius: 0; font: 600 var(--text-xs)/1 var(--font-code); user-select: none; -webkit-user-select: none; touch-action: pan-y; -webkit-tap-highlight-color: transparent; } .term-key:active { background: var(--bg-3); } .term-key-mod.once { color: var(--on-accent); background: var(--accent); border-color: var(--accent); } .term-key-mod.locked { color: var(--on-accent); background: var(--accent); border-color: var(--accent); box-shadow: inset 0 0 0 2px var(--bg-1); } /* (The ux-sweep #8 overflow fade lives on the SCROLLING chip row .acc-cats above; the action row does not scroll, so it carries no fade.) */ /* Handedness (design §5): the default 'right (right thumb) anchors the flex: 1 1 auto; min-width: 0; color: var(--fg-1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* Echo auto-dismiss (slate-ux-sweep #2): once a message has lingered, Sigil adds .fading and the message fades out; it's cleared just after. Duration must match echo-fade-ms in (slate main). Keyframe (not a transition) so it runs on the freshly-rendered element. reduced-motion collapses it below. */ .ledge-echo.fading { animation: ledge-echo-fade 500ms var(--ease) forwards; } /* One host-owned animation replaces the old 200ms WASM polling loop: linger for 4.5s, fade for 0.5s, then animationend clears this exact generation. */ .ledge-echo.echo-active { animation: ledge-echo-fade 500ms var(--ease) 4500ms forwards; } @keyframes ledge-echo-fade { from { opacity: 1; } to { opacity: 0; } } /* Dedicated ≡ button to the *messages* buffer — its own click target so it never competes with the ledge's palette click. */ font-weight: 650; } .ledge-ind { flex: 0 0 auto; display: inline-flex; align-items: center; gap: var(--sp-2); color: var(--fg-2); font-size: var(--text-xs); } @media (max-width: 767px) { .frame { --bottom-chrome-h: calc(100px + env(safe-area-inset-bottom)); } .ledge { height: 36px; padding: 0 var(--sp-2); gap: var(--sp-2); } .ledge-msgs, .ledge-notif, .ledge-ind { min-width: 36px; height: 36px; padding: 0; align-items: center; justify-content: center; } .ledge-notif { margin-left: 0; } } /* The clock/date ledge item (a live item; hidden on small screens by default via .ledge-hide-sm below — the phone has its own clock). */ .ledge-clock { flex: 0 0 auto; color: var(--fg-2); font-size: var(--text-xs); Dimming is purely presentational, so it reverses cleanly on reattach and costs the terminal emulator nothing. ---------------------------------------------------------------------- */ .term-ribbon { flex: 0 0 auto; display: flex; align-items: center; /* This slot is always rendered. Keeping the terminal kernel's keyed DOM sibling stable prevents a disconnect transition from clearing the last known grid while the ribbon is inserted. */ .term-ribbon-slot { flex: 0 0 auto; min-height: 0; } .term-ribbon { display: flex; align-items: center; gap: var(--sp-2); padding: 2px var(--sp-3); background: color-mix(in srgb, var(--warn) 16%, var(--bg-1)); border-bottom: 1px solid color-mix(in srgb, var(--warn) 45%, transparent); the bottom, so the panel lands JUST ABOVE the ledge (and the ledge stays visible + clickable below it). --vvh already excludes the on-screen keyboard. */ .palette-scrim { position: fixed; left: 0; right: 0; z-index: 30; top: var(--vvo, 0px); height: calc(var(--vvh, 100dvh) - var(--ledge-h)); top: var(--vvo, 0px); height: calc(var(--vvh, 100dvh) - var(--bottom-chrome-h, var(--ledge-h))); /* Backdrop is undimmed for now (David's call): the scrim only catches click-outside-to-close; the panel's own top shadow gives it separation. To reintroduce a *very subtle* dim later, set e.g. background: rgba(3,5,10,0.18). */ border-radius: 0; /* Cap the height so context stays visible above the panel; the .pal-list scrolls internally. Leaves headroom above and clears the ledge below. */ max-height: min(52dvh, calc(var(--vvh, 100dvh) - var(--ledge-h) - 2rem)); max-height: min(52dvh, calc(var(--vvh, 100dvh) - var(--bottom-chrome-h, var(--ledge-h)) - 2rem)); display: flex; flex-direction: column; overflow: hidden; box-shadow: var(--shadow-sheet); } y/n could never reach Sigil. Its own class keeps the frame focused, so the confirm keymap resolves. See render-confirm in (slate selector). */ .confirm-scrim { position: fixed; left: 0; right: 0; z-index: 30; top: var(--vvo, 0px); height: calc(var(--vvh, 100dvh) - var(--ledge-h)); top: var(--vvo, 0px); height: calc(var(--vvh, 100dvh) - var(--bottom-chrome-h, var(--ledge-h))); background: transparent; display: flex; align-items: flex-end; /* Never swallow a click: the panel answers keys only, and the buffer behind it must keep working exactly as the ledge echo does. */ the NEXT chord key still reaches on-key:. pointer-events:none so it never eats a click either. Rises above the ledge like the palette. */ .which-key-scrim { position: fixed; left: 0; right: 0; z-index: 29; top: var(--vvo, 0px); height: calc(var(--vvh, 100dvh) - var(--ledge-h)); top: var(--vvo, 0px); height: calc(var(--vvh, 100dvh) - var(--bottom-chrome-h, var(--ledge-h))); background: transparent; display: flex; align-items: flex-end; pointer-events: none; } .which-key-panel { width: 100%; max-height: min(45dvh, calc(var(--vvh, 100dvh) - var(--ledge-h) - 2rem)); width: 100%; max-height: min(45dvh, calc(var(--vvh, 100dvh) - var(--bottom-chrome-h, var(--ledge-h)) - 2rem)); overflow: auto; background: var(--bg-1); border-top: 1px solid var(--edge-1); box-shadow: var(--shadow-sheet); font-family: var(--font-code); padding: var(--sp-2) var(--sp-3); .wk-arrow { color: var(--fg-2); flex: 0 0 auto; } .wk-cmd { color: var(--fg-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .which-key-panel:has(button.wk-row) { pointer-events:auto; } .which-key-panel:has(button.wk-row) .wk-grid { grid-template-columns:repeat(auto-fit, minmax(min(100%, 360px), 1fr)); } .which-key-panel button.wk-row { width:100%; min-height:44px; border:0; background:transparent; color:inherit; font:inherit; text-align:left; cursor:pointer; } .which-key-panel button.wk-row:hover, .which-key-panel button.wk-row:focus-visible { background:var(--bg-2); outline:1px solid var(--accent); outline-offset:-1px; } .wk-row-prefix .wk-cmd { color: var(--fg-2); font-style: italic; } .pal-crumbs { display: flex; align-items: center; gap: 0.2em; flex: 0 0 auto; @media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } /* Preserve dismissal without visually animating the message. */ .ledge-echo.echo-active { animation: ledge-echo-hold 4500ms linear forwards !important; } @keyframes ledge-echo-hold { from { opacity: 1; } to { opacity: 1; } } } /* Transport diagnostics toggle — HIDDEN for now. The 🩺 diag button + .agent-child-identity { display: flex; flex-wrap: wrap; gap: 8px 16px; padding: 10px 0 16px; font-size: 12px; color: var(--fg-2); } .agent-child-identity strong { color: var(--fg-0); }</style> .git-buffer { position:relative; min-width: 0; max-width: 100%; padding: var(--sp-3); overflow: auto; font-family: var(--font-code); font-size: var(--text-code); color: var(--fg-0); } .git-buffer-header { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 0 var(--sp-3); padding: var(--sp-2) var(--sp-3) var(--sp-3); margin-bottom: var(--sp-2); border-bottom: 1px solid var(--edge-1); background: transparent; } .git-header-line { grid-column: 1; display: grid; grid-template-columns: 14ch minmax(0, 1fr); min-height: 1.55em; align-items: baseline; } .git-header-label { color: var(--fg-2); white-space:nowrap; } .git-header-value { color: var(--fg-0); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .git-header-actions { grid-column: 2; grid-row: 1 / span 6; display: flex; align-items: start; gap: var(--sp-2); } .git-header-icon { display:grid; place-items:center; width:30px; height:30px; padding:0; border:1px solid var(--edge-1); border-radius:var(--r-1); background:transparent; color:var(--fg-2); font:inherit; } .git-header-icon:hover, .git-header-icon:focus-visible { border-color:var(--accent); color:var(--accent); } .git-row, .git-diff-row { position: relative; display: flex; width: 100%; min-height: 44px; align-items: center; border: 0; border-radius: 0; padding: var(--sp-2) var(--sp-3); background: transparent; color: var(--fg-0); text-align: left; font: inherit; } .git-row:hover, .git-row:focus-visible, .git-diff-row:hover, .git-diff-row:focus-visible { background: color-mix(in srgb, var(--accent) 7%, transparent); } .git-row.selected, .git-diff-row.selected { background: color-mix(in srgb, var(--accent) 22%, var(--bg-1)); box-shadow: inset 4px 0 0 var(--accent); } .git-section { margin-top: var(--sp-2); border-top: 1px solid var(--edge-1); font-weight: 700; color: var(--fg-1); } .git-section:first-of-type { border-top: 0; } .git-entry { display: grid; grid-template-columns: 3ch 3ch minmax(0, 1fr) auto auto auto; min-height:34px; padding:2px var(--sp-2) 2px var(--sp-5); } .git-entry-state { color: var(--fg-2); } .git-entry-toggle { min-height:30px; border:0; background:transparent; color:var(--fg-2); font:inherit; } .git-entry-path { min-height: 30px; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .git-entry-diff { min-width: 42px; min-height: 30px; padding:0 var(--sp-2); border: 1px solid var(--edge-1); border-radius: var(--r-1); background: transparent; color: var(--fg-1); font: inherit; font-size:var(--text-xs); } .git-entry-diff:hover, .git-entry-diff:focus-visible { border-color: var(--accent); color: var(--accent); } .git-entry-diff, .git-entry-mutation, .git-entry-discard { display:grid; place-items:center; min-width:30px; } .git-entry-mutation { min-height:30px; padding:0 var(--sp-2); margin-left:var(--sp-1); border:1px solid var(--edge-1); border-radius:var(--r-1); background:transparent; color:var(--fg-1); font:inherit; font-size:var(--text-xs); } .git-entry-mutation:hover, .git-entry-mutation:focus-visible { border-color:var(--accent); color:var(--accent); } .git-entry-discard { min-height:30px; padding:0 var(--sp-2); margin-left:var(--sp-1); border:1px solid color-mix(in srgb, var(--err) 45%, var(--edge-1)); border-radius:var(--r-1); background:transparent; color:var(--err); font:inherit; font-size:var(--text-xs); } .git-entry-discard:hover, .git-entry-discard:focus-visible { border-color:var(--err); background:color-mix(in srgb, var(--err) 10%, transparent); } .git-section-toggle { flex:1; min-width:0; border:0; background:transparent; color:inherit; text-align:left; font:inherit; font-weight:inherit; } .git-conflicts { color: var(--err); } .git-untracked { color: var(--ok); } .git-commit { display:grid; grid-template-columns:9ch minmax(0, 1fr); gap:var(--sp-2); padding-left:var(--sp-5); color:var(--fg-1); } .git-commit-oid { color:var(--fg-2); font-variant-numeric:tabular-nums; } .git-commit-subject { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } /* History keeps one bounded page and one explicitly opened review. */ .git-log.git-buffer { container:history / inline-size; display:flex; flex-direction:column; overflow:hidden; padding:0; } .git-log-header { position:relative; display:flex; align-items:center; flex-wrap:wrap; gap:16px; padding:22px 24px 16px; border-bottom:1px solid var(--edge-1); } .git-log-header > div:first-child { flex:1; min-width:0; } .git-log-header h1 { font:600 1.3em var(--font-code); margin:6px 0; } .git-log .git-eyebrow { font-size:.75em; letter-spacing:.13em; color:var(--fg-2); } .git-log-path { color:var(--fg-2); font-size:.8em; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .git-log-workbench { flex:1; min-height:0; display:grid; grid-template-columns:minmax(0,1fr); grid-template-rows:minmax(0,2fr) minmax(0,3fr); overflow:hidden; } .git-log-list { min-width:0; min-height:0; overflow:auto; } .git-log-pane-label { position:sticky; top:0; z-index:2; display:flex; align-items:center; justify-content:space-between; gap:8px; padding:5px 12px; background:var(--bg-1); border-bottom:1px solid var(--edge-1); color:var(--fg-2); font-size:.75em; } .git-log-pane-label span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .git-log[data-focus="list"] .git-log-list > .git-log-pane-label, .git-log[data-focus="review"] .git-log-review > .git-log-pane-label { color:var(--accent); box-shadow:inset 3px 0 var(--accent); background:var(--bg-2); } .git-log-review-body { flex:1; min-height:0; overflow:auto; outline:none; } .git-log-pagination { display:flex; align-items:center; gap:12px; padding:12px; color:var(--fg-2); font-size:.8em; } .git-log-pagination span { flex:1; } .git-log button { font:inherit; color:inherit; background:transparent; border:0; padding:4px 8px; cursor:pointer; } .git-log .git-header-actions button, .git-log-pagination button, .git-log-commit-header button { border:1px solid var(--edge-1); border-radius:var(--r-1); font-size:.85em; } .git-log button:hover { background:var(--bg-2); color:var(--accent); } .git-log kbd { font:inherit; font-size:.9em; color:var(--accent); margin-right:7px; white-space:nowrap; } .git-log button:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; } .git-log .git-row { scroll-margin-top:40px; min-height:32px; padding:0 12px 0 6px; gap:10px; text-align:left; } .git-log-graph { display:inline-block; height:32px; flex:none; background:var(--accent); mask-size:100% 100%; mask-repeat:no-repeat; } .git-log .git-commit-oid { color:var(--accent); font-size:.85em; } .git-log-summary { display:flex; align-items:center; gap:8px; flex:1; min-width:0; } .git-log-subject { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .git-log-refs { color:var(--ok); border:1px solid color-mix(in srgb,var(--ok) 35%,transparent); padding:1px 5px; border-radius:4px; font-size:.75em; max-width:20ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .git-log-author { color:var(--fg-2); font-size:.8em; max-width:16ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .git-log-review { border-top:1px solid var(--edge-1); min-width:0; min-height:0; display:flex; flex-direction:column; overflow:hidden; } .git-log-empty { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:16px; color:var(--fg-2); padding:48px 20px; text-align:center; } .git-log-empty strong { color:var(--fg-0); } .git-log-commit-header { padding:22px; } .git-log-commit-header h2 { font:600 1.2em var(--font-code); line-height:1.5; margin:10px 0; overflow-wrap:anywhere; } .git-log-metadata { display:flex; flex-wrap:wrap; gap:12px; color:var(--fg-2); font-size:.85em; } .git-log-oid { overflow-wrap:anywhere; color:var(--accent); font-size:.8em; margin:12px 0; } .git-log-parents { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-top:12px; font-size:.85em; color:var(--fg-2); } .git-log-message { overflow-wrap:anywhere; margin-top:18px; } .git-log-message .md-body { max-width:none; margin:0; font:inherit; font-size:.9em; line-height:1.6; }Showing the first 500 of 2683 diff lines for this file. This diff is INCOMPLETE; read the file or clone the repository for the rest.
packages/slate/assets/sw.jsmodified
// caches.match resolve them against self.location, and the fetch router keys// off path SUFFIXES rather than absolute pathnames.const VERSION = '2026-09-08-agent-shared-node-1';const VERSION = '2026-09-08-agent-shared-node-merge-1';const CACHE_NAME = `slate-shell-${VERSION}`;// The app shell — exactly what the built index.html loads at runtime (verified 'vendor/browser_wasi_shim/debug.js', 'vendor/browser_wasi_shim/wasi_defs.js', 'slate-registry.mjs', 'terminal-prediction.mjs', 'terminal-compose.mjs', 'terminal-copy.mjs', 'folder-browser.mjs', // The WASM must be cached with its server Content-Type intact — // application/wasm lets compileStreaming accept the cached Response offline. // Bytecode builds fetch lib.bundle through buildFs(); the runtime fetch handler } }), ); self.skipWaiting(); // Do not activate over a running Slate client. The installed worker waits // until the user explicitly invokes Slate: Update Client, whose message // handler below is the sole skipWaiting() path.});self.addEventListener('activate', (ev) => { ev.waitUntil( caches.keys().then((names) => Promise.all( names.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n)), )).then(() => self.clients.claim()), )), );});// Let the page ask a waiting SW to activate immediately (update flow).self.addEventListener('message', (ev) => { if (ev && ev.data && ev.data.type === 'skip-waiting') self.skipWaiting(); if (ev && ev.data && ev.data.type === 'slate-client-diagnostics' && ev.ports && ev.ports[0]) { ev.ports[0].postMessage({ version: VERSION, cache: CACHE_NAME }); }});self.addEventListener('fetch', (ev) => {packages/slate/assets/terminal-compose.mjsadded
// Native draft editing stays outside Slate's live key router. Drafts belong to// individual terminals and survive page/view switches for this browser session.export function createTerminalDeck({ document, modifier, send, focusKeys, cancelKey }) { const drafts = new Map(); const histories = new Map(); let historyPanel, historyItems; let historyOpen = false, historyHintShown = false; let deck, input, target = '', page = 'keys', composing = false; let gesture = null, clickGuard = null, shiftEnter = false, compositionEnter = false; const closest = (node, selector) => node?.closest?.(selector); const field = node => closest(node, '.terminal-compose-input'); const mod = node => closest(node, '[data-slate-click^="sticky-mod:"]'); const focused = () => !!input && document.activeElement === input; function save() { if (target && input) drafts.set(target, { text: input.value, page, start: input.selectionStart, end: input.selectionEnd }); } function visibility(node, hide) { if (node && node.hidden !== hide) node.hidden = hide; } function sync() { const nextDeck = document.querySelector('.accessory-key-deck'); const nextInput = nextDeck?.querySelector('.terminal-compose-input'); const nextTarget = nextDeck?.getAttribute('data-compose-target') || ''; if (target !== nextTarget || input !== nextInput) { save(); target = nextTarget; input = nextInput; composing = false; historyOpen = false; const status = nextDeck?.querySelector('.terminal-compose-status'); if (status) status.textContent = ''; const draft = drafts.get(target); page = target ? (draft?.page || 'keys') : 'keys'; if (input) { input.value = draft?.text || ''; if (draft) input.setSelectionRange(draft.start, draft.end); } } deck = nextDeck; if (!deck) return; visibility(deck.querySelector('.terminal-key-page'), page === 'compose'); visibility(deck.querySelector('.terminal-compose-page'), page !== 'compose'); syncHistory(); } function syncHistory() { if (!deck || !input) return; const items = histories.get(target) || []; const empty = !input.value; const button = deck.querySelector('.terminal-compose-send'); const disabled = empty && !items.length; if (button.disabled !== disabled) button.disabled = disabled; const description = empty ? 'Text history' : 'Send text without Enter'; if (button.getAttribute('aria-label') !== description) button.setAttribute('aria-label', description); if (button.title !== description) button.title = description; const label = empty ? '◷' : 'Send'; if (button.textContent !== label) button.textContent = label; button.classList.toggle('history', empty); if (!empty) historyOpen = false; const panel = deck.querySelector('.terminal-compose-history'); visibility(panel, !historyOpen); if (panel && historyOpen && (panel !== historyPanel || items !== historyItems || panel.children.length !== items.length + 1)) { historyPanel = panel; historyItems = items; panel.replaceChildren(); const title = document.createElement('div'); title.className = 'terminal-history-title'; title.textContent = 'Recent text'; panel.appendChild(title); items.forEach((text, index) => { const entry = document.createElement('button'); entry.type = 'button'; entry.dataset.terminalHistory = String(index); entry.textContent = text.length > 160 ? text.slice(0, 160) + '…' : text; panel.appendChild(entry); }); } } function recall(index) { // Typing while the list is open must never replace a newer draft. if (!target || input.value) return; const text = (histories.get(target) || [])[index]; if (text === undefined) return; input.value = text; input.setSelectionRange(text.length, text.length); historyOpen = false; save(); syncHistory(); input.focus({ preventScroll: true }); } function show(next) { sync(); if (!target) return; page = next; historyOpen = false; save(); sync(); if (page === 'compose') input.focus({ preventScroll: true }); else focusKeys(); } function submit() { // A view switch can render before the host's reconciliation callback runs. // Do not reinterpret that stale field's Enter as permission to send another // terminal's draft. The next explicit send can use the newly selected one. const previous = target; sync(); if (!target || previous !== target || composing || !input.value) return false; const text = input.value; let accepted = false; try { accepted = send(target, text); } catch (_) {} const status = deck.querySelector('.terminal-compose-status'); const message = accepted ? '' : 'Not sent. Return to the live terminal or reconnect, then try again.'; if (status.textContent !== message) status.textContent = message; if (accepted) { histories.set(target, [text, ...(histories.get(target) || [])].slice(0, 5)); input.value = ''; if (!historyHintShown) { const hint = 'Text history — tap the clock to recall'; status.textContent = hint; historyHintShown = true; setTimeout(() => { if (status.textContent === hint) status.textContent = ''; }, 5000); } } save(); syncHistory(); return accepted; } function changeModifier(node) { const which = node.getAttribute('data-slate-click').slice('sticky-mod:'.length); const state = modifier(which); if (!['off', 'once', 'locked'].includes(state)) return; for (const chip of document.querySelectorAll(`[data-slate-click="sticky-mod:${which}"]`)) { for (const value of ['off', 'once', 'locked']) chip.classList.toggle(value, value === state); chip.setAttribute('aria-pressed', state === 'off' ? 'false' : 'true'); } const armed = !!document.querySelector('.acc-mod.once, .acc-mod.locked, .term-key-mod.once, .term-key-mod.locked'); for (const chip of document.querySelectorAll('.acc-kbd-chip')) chip.classList.toggle('live', armed); } function guardClick(event) { clickGuard = { x: event.clientX, y: event.clientY }; } document.addEventListener('pointerdown', event => { clickGuard = null; if (closest(event.target, '[data-slate-host-action="terminal-compose:open"]')) { event.preventDefault(); event.stopImmediatePropagation(); return; } if (historyOpen && !closest(event.target, '.terminal-compose-history, .terminal-compose-send')) { historyOpen = false; syncHistory(); } if (field(event.target)) { event.stopImmediatePropagation(); return; } const modifierNode = mod(event.target); const bar = closest(event.target, '.accessory-key-deck'); if ((!bar && !modifierNode) || event.button !== 0 || event.isPrimary === false) return; gesture = { id: event.pointerId, x: event.clientX, y: event.clientY, bar, modifierNode, moved: false }; if (modifierNode) { // Keep any native editing surface focused, including the rendered editor. // Dispatch on pointerup so horizontal swipes cannot arm a modifier. event.preventDefault(); event.stopImmediatePropagation(); } else if (closest(event.target, 'button')) event.preventDefault(); }, true); document.addEventListener('pointermove', event => { if (!gesture || event.pointerId !== gesture.id) return; if (Math.abs(event.clientX - gesture.x) > 10 || Math.abs(event.clientY - gesture.y) > 10) { gesture.moved = true; cancelKey(); } }, true); document.addEventListener('pointercancel', () => { gesture = null; cancelKey(); }, true); document.addEventListener('pointerup', event => { const pending = gesture; gesture = null; if (!pending || event.pointerId !== pending.id) return; const dx = event.clientX - pending.x, dy = event.clientY - pending.y; if (pending.bar && Math.abs(dx) >= 40 && Math.abs(dx) > Math.abs(dy)) { cancelKey(); guardClick(event); event.preventDefault(); event.stopImmediatePropagation(); show(dx < 0 ? 'compose' : 'keys'); } else if (pending.modifierNode) { guardClick(event); event.preventDefault(); event.stopImmediatePropagation(); if (!pending.moved) changeModifier(pending.modifierNode); } else if (pending.moved) guardClick(event); }, true); document.addEventListener('click', event => { if (clickGuard && (event.detail !== 0 || event.pointerType === 'touch' || event.pointerType === 'pen') && Math.abs(event.clientX - clickGuard.x) < 32 && Math.abs(event.clientY - clickGuard.y) < 32) { clickGuard = null; event.preventDefault(); event.stopImmediatePropagation(); return; } const historyEntry = closest(event.target, '[data-terminal-history]'); if (historyEntry) { event.preventDefault(); event.stopImmediatePropagation(); recall(Number(historyEntry.dataset.terminalHistory)); return; } const modifierNode = mod(event.target); if (modifierNode) { event.preventDefault(); event.stopImmediatePropagation(); changeModifier(modifierNode); return; } const button = closest(event.target, '[data-slate-host-action="terminal-compose:open"], .terminal-compose-back, .terminal-compose-send'); if (button) { event.preventDefault(); event.stopImmediatePropagation(); if (button.matches('.terminal-compose-send')) { sync(); if (input.value) submit(); else { historyOpen = !historyOpen; syncHistory(); } input?.focus({ preventScroll: true }); } else show(button.matches('.terminal-compose-back') ? 'keys' : 'compose'); } else if (field(event.target)) event.stopImmediatePropagation(); }, true); // Capture before the frame and host shortcuts. Do not prevent native editing // defaults: selection, clipboard shortcuts, autocorrect and IME remain local. document.addEventListener('keydown', event => { if (!field(event.target)) return; event.stopImmediatePropagation(); if (event.key === 'Escape' && historyOpen) { event.preventDefault(); historyOpen = false; syncHistory(); return; } if (event.key !== 'Enter') return; if (event.isComposing || composing || event.keyCode === 229) { compositionEnter = true; return; } shiftEnter = event.shiftKey; if (!shiftEnter) { event.preventDefault(); submit(); } }, true); document.addEventListener('keyup', event => { if (!field(event.target)) return; event.stopImmediatePropagation(); shiftEnter = false; compositionEnter = false; }, true); document.addEventListener('beforeinput', event => { if (!field(event.target)) return; event.stopImmediatePropagation(); if (event.inputType === 'insertParagraph' || event.inputType === 'insertLineBreak') { if (shiftEnter) return; event.preventDefault(); if (!event.isComposing && !composing && !compositionEnter) submit(); compositionEnter = false; } }, true); document.addEventListener('input', event => { if (field(event.target)) { event.stopImmediatePropagation(); save(); syncHistory(); } }, true); for (const type of ['paste', 'copy', 'cut']) document.addEventListener(type, event => { if (field(event.target)) event.stopImmediatePropagation(); }, true); for (const type of ['compositionstart', 'compositionupdate', 'compositionend']) { document.addEventListener(type, event => { if (!field(event.target)) return; event.stopImmediatePropagation(); composing = type !== 'compositionend'; save(); }, true); } return { sync, ownsFocus: focused };}packages/slate/assets/terminal-copy.mjsadded
// Selection coordinates refer to frozen terminal rows, never recycled DOM nodes.export class TerminalCopySelection { constructor({ html, rows, cols, top, point }) { this.html = html; this.lines = rows.map(text => [...text.replace(/ +$/, '')]); this.cols = cols; this.height = 1; this.top = top; this.point = this.clamp(point); this.mark = null; } clamp({ row, col }) { row = Math.max(0, Math.min(this.lines.length - 1, row)); return { row, col: Math.max(0, Math.min(this.lines[row].length, col)) }; } setPoint(row, col, reveal = false) { this.point = this.clamp({ row, col }); if (reveal) { if (this.point.row < this.top) this.top = this.point.row; if (this.point.row >= this.top + this.height) this.top = this.point.row - this.height + 1; } this.scroll(0); } scroll(delta) { this.top = Math.max(0, Math.min(Math.max(0, this.lines.length - this.height), this.top + delta)); } setMark() { this.mark = { ...this.point }; } region() { if (!this.mark) return null; const a = this.mark, b = this.point; return a.row < b.row || (a.row === b.row && a.col <= b.col) ? [a, b] : [b, a]; } text() { const range = this.region(); if (!range) return ''; const [a, b] = range, out = []; for (let row = a.row; row <= b.row; row++) out.push(this.lines[row].slice(row === a.row ? a.col : 0, row === b.row ? b.col : undefined).join('')); return out.join('\n'); } step(direction) { let { row, col } = this.point; if (direction > 0) { if (col < this.lines[row].length) col++; else if (row + 1 < this.lines.length) { row++; col = 0; } } else if (col > 0) col--; else if (row > 0) { row--; col = this.lines[row].length; } this.setPoint(row, col, true); } word(direction) { const isWord = ch => !!ch && /[\p{L}\p{N}_]/u.test(ch); const char = () => this.lines[this.point.row][this.point.col - (direction < 0 ? 1 : 0)]; const advance = () => { const old = this.point; this.step(direction); return old.row !== this.point.row || old.col !== this.point.col; }; while (!isWord(char())) if (!advance()) return; while (isWord(char())) if (!advance()) return; } key(spec) { const { row, col } = this.point; if (['C-Space', 'C- ', 'C-@', 'C-Spacebar'].includes(spec)) this.setMark(); else if (['ArrowLeft', 'C-b'].includes(spec)) this.step(-1); else if (['ArrowRight', 'C-f'].includes(spec)) this.step(1); else if (['ArrowUp', 'C-p'].includes(spec)) this.setPoint(row - 1, col, true); else if (['ArrowDown', 'C-n'].includes(spec)) this.setPoint(row + 1, col, true); else if (['Home', 'C-a'].includes(spec)) this.setPoint(row, 0, true); else if (['End', 'C-e'].includes(spec)) this.setPoint(row, this.lines[row].length, true); else if (['PageUp', 'M-v'].includes(spec)) this.setPoint(row - Math.max(1, this.height - 1), col, true); else if (['PageDown', 'C-v'].includes(spec)) this.setPoint(row + Math.max(1, this.height - 1), col, true); else if (['M-<', 'C-Home'].includes(spec)) this.setPoint(0, 0, true); else if (['M->', 'C-End'].includes(spec)) this.setPoint(this.lines.length - 1, this.lines.at(-1).length, true); else if (spec === 'M-b') this.word(-1); else if (spec === 'M-f') this.word(1); }}// Report clipboard failure truthfully and retain the selection/staged payload.export async function writeTerminalClipboard(document, navigator, text) { if (navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(text); return true; } catch (_) {} } const active = document.activeElement; const start = active?.selectionStart, end = active?.selectionEnd; const field = document.createElement('textarea'); field.value = text; field.style.cssText = 'position:fixed;opacity:0;left:0;top:0'; document.body.appendChild(field); field.select(); let copied = false; try { copied = document.execCommand('copy'); } catch (_) {} field.remove(); active?.focus({ preventScroll: true }); if (typeof start === 'number') active.setSelectionRange(start, end); return copied;}export function createTerminalCopy({ document, snapshot, end, peek, acknowledge, clipboard, focusKeys, focusPane, consumeSticky, invalidate }) { const copies = new Map(); const closest = (node, selector) => node?.closest?.(selector); let gesture = null, clickGuard = null; const menuSelector = '.acc-actions[data-accessory-category="terminal-copy"]'; const messages = new Map(); // These controls sit outside the terminal's DOM-owned grid. Rewriting an // unchanged attribute schedules afterRender again through the app observer. const attribute = (node, name, value) => { if (node && node.getAttribute(name) !== value) node.setAttribute(name, value); }; const textContent = (node, text) => { if (node.textContent !== text) node.textContent = text; }; const hidden = (node, value) => { if (node && node.hidden !== value) node.hidden = value; }; const actions = { start: 'Enter copy mode', mark: 'Mark', copy: 'Copy selection', cancel: 'Cancel', received: 'Copy received text' }; const focusedGrid = () => document.querySelector('.pane.focused .tmux-pane.active .term-content, .pane.focused .tmux-pane.focused .term-content') || document.querySelector('.pane.focused .term-content'); const gridFor = tools => { const pane = tools?.closest('.pane'); return pane ? (pane.querySelector('.tmux-pane.active .term-content, .tmux-pane.focused .term-content') || pane.querySelector('.term-content')) : focusedGrid(); }; const nameFor = grid => grid?.getAttribute('data-term') || ''; const current = () => copies.get(nameFor(focusedGrid())); const toolsFor = copy => { const menu = document.querySelector(menuSelector); return menu?.dataset.copyTarget === copy.name ? menu : copy.el?.closest('.pane')?.querySelector('.terminal-copy-header-tools'); }; function status(tools, message) { const name = tools?.dataset.copyTarget || nameFor(focusedGrid()); messages.set(name, message); for (const node of document.querySelectorAll('.terminal-copy-status')) { if (node.parentElement.dataset.copyTarget === name) textContent(node, message); } } function sync() { // Desktop controls occupy the existing mode line. Mobile uses only the // normal accessory category; opening it does not create a snapshot. for (const header of document.querySelectorAll('.terminal-copy-header-tools')) { if (!header.querySelector('[data-terminal-copy]')) { for (const [action, label] of Object.entries(actions)) { const button = document.createElement('button'); button.type = 'button'; button.dataset.terminalCopy = action; button.textContent = action === 'start' ? 'Copy' : label; header.appendChild(button); } } } const menu = document.querySelector(menuSelector); for (const node of document.querySelectorAll('.accessory-bar .terminal-copy-status')) { if (node.parentElement !== menu) { textContent(node, ''); node.classList.remove('terminal-copy-status'); } } for (const row of document.querySelectorAll('.acc-actions.terminal-copy-tools')) { if (row === menu) continue; row.classList.remove('terminal-copy-tools'); row.querySelector('.terminal-copy-status')?.remove(); } if (menu) { if (!menu.classList.contains('terminal-copy-tools')) menu.classList.add('terminal-copy-tools'); for (const button of menu.querySelectorAll('[data-slate-host-action]')) attribute(button, 'data-terminal-copy', button.dataset.slateHostAction.slice('terminal-copy:'.length)); } for (const tools of document.querySelectorAll(`${menuSelector}, .terminal-copy-header-tools`)) { const el = gridFor(tools), name = nameFor(el), copy = copies.get(name); attribute(tools, 'data-copy-target', name); const visible = (action, show) => { const button = tools.querySelector(`[data-terminal-copy="${action}"]`); hidden(button, !show); }; for (const action of ['mark', 'copy', 'cancel']) visible(action, !!copy); visible('start', !copy); const outer = el?.closest('[data-tmux-outer]')?.getAttribute('data-tmux-outer'); const clipTarget = peek(name) ? name : (outer || name); attribute(tools, 'data-clip-target', clipTarget); visible('received', !!peek(clipTarget)); attribute(tools.querySelector('[data-terminal-copy="mark"]'), 'aria-pressed', copy?.model.mark ? 'true' : 'false'); const take = tools.querySelector('[data-terminal-copy="copy"]'); const disabled = !copy?.model.text(); if (take && take.disabled !== disabled) take.disabled = disabled; let message = tools.querySelector('.acc-host-status, .terminal-copy-status'); if (!message && tools.matches('.terminal-copy-header-tools')) { message = document.createElement('span'); message.className = 'terminal-copy-status'; message.setAttribute('role', 'status'); tools.appendChild(message); } if (message) { if (!message.classList.contains('terminal-copy-status')) message.classList.add('terminal-copy-status'); textContent(message, messages.get(name) || ''); } } for (const label of document.querySelectorAll('.terminal-copy-label')) { const pane = label.closest('.pane'); const frozen = [...pane.querySelectorAll('.term-content')].some(el => copies.has(nameFor(el))); hidden(label, !frozen); textContent(label, frozen ? 'Copy mode' : ''); attribute(label, 'title', 'Frozen terminal snapshot'); } const chip = document.querySelector('[data-slate-click="acc-cat:terminal-copy"]'); if (chip) { const el = focusedGrid(), name = nameFor(el); const outer = el?.closest('[data-tmux-outer]')?.getAttribute('data-tmux-outer'); const received = !!(peek(name) || (outer && peek(outer))); chip.classList.toggle('copy-received', received); attribute(chip, 'aria-label', received ? 'Copy — received text available' : 'Copy'); attribute(chip, 'title', received ? 'Received terminal text is ready to copy' : 'Terminal copy actions'); } for (const [name, copy] of copies) { const el = [...document.querySelectorAll('.term-content')].find(el => nameFor(el) === name); if (el) { copy.el = el; render(copy); } } } function render(copy) { const el = copy.el; if (!el?.isConnected) return; const model = copy.model, style = getComputedStyle(el); const height = parseFloat(style.getPropertyValue('--cell-h')) || 16; const width = parseFloat(style.getPropertyValue('--cell-w')) || 8; model.height = Math.max(1, Math.floor((el.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)) / height)); model.scroll(0); if (!el.classList.contains('terminal-copy-active')) el.classList.add('terminal-copy-active'); const signature = JSON.stringify([model.top, model.height, model.point, model.mark, width, height]); if (copy.signature === signature && copy.firstRow === el.firstChild && el.children.length === copy.rowCount) return; copy.signature = signature; const fragment = document.createDocumentFragment(); const region = model.region(); for (let i = model.top; i < Math.min(model.lines.length, model.top + model.height); i++) { const row = document.createElement('div'); row.className = 'tr'; row.dataset.copyRow = String(i); row.innerHTML = model.html[i]; if (region && i >= region[0].row && i <= region[1].row) { const start = i === region[0].row ? region[0].col : 0; const end = i === region[1].row ? region[1].col : Math.max(model.lines[i].length, model.cols); if (end > start) { const selection = document.createElement('span'); selection.className = 'terminal-copy-region'; selection.style.left = `${start * width}px`; selection.style.width = `${(end - start) * width}px`; row.appendChild(selection); } } if (i === model.point.row) { const point = document.createElement('span'); point.className = 'terminal-copy-point'; point.style.left = `${model.point.col * width}px`; row.appendChild(point); } fragment.appendChild(row); } el.replaceChildren(fragment); copy.firstRow = el.firstChild; copy.rowCount = el.children.length; } function start(el) { const name = nameFor(el); if (!name || copies.has(name)) return; const payload = snapshot(name); if (!payload) return; try { const [header, ...html] = payload.split('\n'); const [cols, height, top, row, col] = header.split(' ').map(Number); if (!html.length || ![cols, height, top, row, col].every(Number.isFinite)) throw Error('Invalid snapshot'); const template = document.createElement('template'); const rows = html.map(line => { template.innerHTML = line; return template.content.textContent; }); const model = new TerminalCopySelection({ html, rows, cols, top, point: { row, col } }); model.height = height; copies.set(name, { name, el, model }); sync(); status(toolsFor(copies.get(name)), 'Tap a start, Mark, then tap an end.'); focusKeys(); } catch (error) { end(name); throw error; } } function cancel(copy) { if (!copy) return; copies.delete(copy.name); copy.el?.classList.remove('terminal-copy-active'); end(copy.name); status(toolsFor(copy), ''); sync(); invalidate(); } async function copyRegion(copy) { if (!copy) return; const text = copy.model.text(); if (!text) { status(toolsFor(copy), 'Set Mark, then move to the end of the text.'); return; } const ok = await clipboard(text); // A late clipboard result cannot close a subsequently opened copy session. if (copies.get(copy.name) !== copy) return; if (ok) { const tools = toolsFor(copy); cancel(copy); status(tools, 'Copied'); } else status(toolsFor(copy), 'Copy failed. Selection retained; tap Copy to retry.'); } async function received(tools) { const text = peek(tools?.dataset.clipTarget || ''); if (!text) return; if (await clipboard(text)) { acknowledge(text); sync(); status(tools, 'Copied'); } else status(tools, 'Copy failed. Received text retained; tap to retry.'); } function key(spec) { const copy = current(); if (!copy) return false; spec = consumeSticky(spec); if (spec === 'Escape' || spec === 'C-g') cancel(copy); else if (spec === 'M-w' || spec === 'C-S-c' || spec === 'C-S-C') void copyRegion(copy); else { copy.model.key(spec); render(copy); sync(); } return true; // Unbound input is also local while frozen. } const isField = node => closest(node, 'textarea, input:not(#slate-key-capture), [contenteditable]'); document.addEventListener('keydown', event => { if (isField(event.target) || !current()) return; if (['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)) return; if (event.isComposing || event.keyCode === 229 || event.key === 'Unidentified') return; const spec = (event.ctrlKey || event.metaKey ? 'C-' : '') + (event.altKey ? 'M-' : '') + (event.ctrlKey && event.shiftKey && /^[cC]$/.test(event.key) ? 'S-' : '') + ((event.ctrlKey || event.altKey) && /^[A-Z]$/.test(event.key) ? event.key.toLowerCase() : event.key); event.preventDefault(); event.stopImmediatePropagation(); key(spec); }, true); document.addEventListener('pointerdown', event => { clickGuard = null; const tools = closest(event.target, '.terminal-copy-tools, .terminal-copy-header-tools, [data-slate-click="acc-cat:terminal-copy"]'); if (tools) { event.preventDefault(); event.stopImmediatePropagation(); return; } const el = closest(event.target, '.term-content'); const copy = copies.get(nameFor(el)); if (!copy || event.button !== 0) return; event.preventDefault(); event.stopImmediatePropagation(); focusPane(el); gesture = { copy, id: event.pointerId, x: event.clientX, y: event.clientY, last: event.clientY, moved: false, remainder: 0 }; copy.el.setPointerCapture(event.pointerId); }, true); document.addEventListener('pointermove', event => { if (!gesture || gesture.id !== event.pointerId) return; event.preventDefault(); event.stopImmediatePropagation(); const g = gesture, height = parseFloat(getComputedStyle(g.copy.el).getPropertyValue('--cell-h')) || 16; if (Math.abs(event.clientX - g.x) + Math.abs(event.clientY - g.y) > 10) g.moved = true; if (!g.moved) return; g.remainder += g.last - event.clientY; g.last = event.clientY; const rows = Math.trunc(g.remainder / height); g.remainder -= rows * height; g.copy.model.scroll(rows); render(g.copy); }, true); document.addEventListener('pointerup', event => { const g = gesture; if (!g || g.id !== event.pointerId) return; gesture = null; event.preventDefault(); event.stopImmediatePropagation(); clickGuard = true; if (!g.moved) { const el = g.copy.el, rect = el.getBoundingClientRect(), style = getComputedStyle(el); const width = parseFloat(style.getPropertyValue('--cell-w')) || 8, height = parseFloat(style.getPropertyValue('--cell-h')) || 16; const row = g.copy.model.top + Math.floor((event.clientY - rect.top - parseFloat(style.paddingTop)) / height); const col = Math.round((event.clientX - rect.left - parseFloat(style.paddingLeft)) / width); g.copy.model.setPoint(row, col); render(g.copy); sync(); } }, true); document.addEventListener('pointercancel', () => { gesture = null; }, true); document.addEventListener('click', event => { const button = closest(event.target, '[data-terminal-copy]'); if (button) { event.preventDefault(); event.stopImmediatePropagation(); const tools = button.closest('.terminal-copy-tools, .terminal-copy-header-tools'), el = gridFor(tools), copy = copies.get(nameFor(el)); focusPane(el); switch (button.dataset.terminalCopy) { case 'start': start(el); break; case 'mark': if (copy) { copy.model.setMark(); sync(); focusKeys(); } break; case 'copy': void copyRegion(copy); break; case 'cancel': cancel(copy); break; case 'received': void received(tools); break; } } else if (clickGuard || copies.has(nameFor(closest(event.target, '.term-content')))) { clickGuard = null; event.preventDefault(); event.stopImmediatePropagation(); } }, true); for (const type of ['mousedown', 'mouseup', 'mousemove', 'touchstart', 'touchmove', 'touchend', 'contextmenu', 'wheel']) { document.addEventListener(type, event => { const copy = copies.get(nameFor(closest(event.target, '.term-content'))); if (!copy) return; if (event.cancelable) event.preventDefault(); event.stopImmediatePropagation(); if (type === 'wheel') { const height = parseFloat(getComputedStyle(copy.el).getPropertyValue('--cell-h')) || 16; const delta = event.deltaMode === 1 ? event.deltaY : event.deltaMode === 2 ? event.deltaY * copy.model.height : event.deltaY / height; copy.model.scroll(Math.sign(delta) * Math.max(1, Math.round(Math.abs(delta)))); render(copy); } }, { capture: true, passive: false }); } document.addEventListener('copy', event => { const copy = current(); if (!copy || isField(event.target)) return; const text = copy.model.text(); if (!text || !event.clipboardData) return; event.preventDefault(); event.stopImmediatePropagation(); event.clipboardData.setData('text/plain', text); }, true); return { sync, key, focused: () => !!current(), active: name => copies.has(name), paint(name, el) { const copy = copies.get(name); if (!copy) return false; copy.el = el; render(copy); return true; }, received, report: () => [...copies.values()].map(c => ({ name: c.name, top: c.model.top, point: c.model.point, mark: c.model.mark, text: c.model.text() })) };}packages/slate/assets/terminal-prediction.mjsadded
// Disposable UI state. Frames are copies of server-authoritative VT state;// this model never writes into the emulator or sends terminal input.export class TerminalPrediction { constructor() { this.frame = null; this.pending = []; this.armed = false; this.slow = 0; this.fast = 0; this.backoffUntil = 0; this.stats = { confirmed: 0, mismatches: 0, expired: 0, arms: 0, disarms: 0 }; } reset(now, mismatch = false) { this.pending = []; if (this.armed) this.stats.disarms++; this.armed = false; this.slow = this.fast = 0; if (mismatch) { this.stats.mismatches++; this.backoffUntil = now + 1000; } } safe(frame) { // Codex uses a single-cell › prompt. Permit that fixed glyph in the // authoritative prefix; predicted input remains printable ASCII. Do not // admit arbitrary Unicode here: string offsets must still match VT cells. return frame && frame.safe && Number.isInteger(frame.cols) && frame.cols > 1 && Number.isInteger(frame.row) && frame.row >= 0 && Number.isInteger(frame.col) && frame.col >= 0 && frame.col < frame.cols - 1 && Number.isInteger(frame.serial) && frame.serial >= 0 && typeof frame.text === 'string' && frame.text.length === frame.cols && /^[\x20-\x7e\u203a]*$/.test(frame.text) && /^ *$/.test(frame.text.slice(frame.col)); } noteLatency(ms, now) { // Count independent confirmed frames, not several keys in one output burst. if (ms > 150) { this.slow++; this.fast = 0; if (!this.armed && this.slow >= 2 && now >= this.backoffUntil) { this.armed = true; this.stats.arms++; } } else if (ms < 80) { this.fast++; this.slow = 0; if (this.armed && this.fast >= 2) { this.armed = false; this.stats.disarms++; } } else this.slow = this.fast = 0; } update(next, now) { this.expire(now); const before = this.frame; if (!this.safe(next)) this.reset(now); else if (before && this.pending.length) { const count = next.col - before.col; const expected = before.text.slice(0, before.col) + this.pending.slice(0, count).map(item => item.key).join('') + before.text.slice(before.col + count); if (next.row === before.row && next.cols === before.cols && count > 0 && count <= this.pending.length && next.serial > before.serial && next.text === expected) { const done = this.pending.splice(0, count); this.stats.confirmed += count; this.noteLatency(now - done[0].at, now); } else if (next.row !== before.row || next.col !== before.col || next.cols !== before.cols || next.text !== before.text || next.serial !== before.serial) this.reset(now, true); } else if (before && (next.row !== before.row || next.col !== before.col || next.cols !== before.cols || next.text !== before.text || next.serial !== before.serial)) this.reset(now); this.frame = { ...next }; } input(key, serial, now) { this.expire(now); const frame = this.frame; if (!this.safe(frame) || frame.serial !== serial || !/^[\x20-\x7e]$/.test(key) || this.pending.length >= 64 || frame.col + this.pending.length >= frame.cols - 1) { this.reset(now); return; } const predicted = this.armed && now >= this.backoffUntil && this.pending.every(item => item.predicted); this.pending.push({ key, at: now, predicted }); } expire(now) { if (this.pending.length && now - this.pending[0].at >= 2000) { this.stats.expired++; this.reset(now, true); } } overlay(now) { this.expire(now); if (!this.armed || !this.pending.length || !this.pending.every(item => item.predicted)) return null; const age = now - this.pending[0].at; return { row: this.frame.row, col: this.frame.col, text: this.pending.map(item => item.key).join(''), stale: age >= 250, wakeIn: Math.max(1, (age < 250 ? 250 : 2000) - age) }; }}packages/slate/package.sglmodified
name: "slate" ;; version inherited from the workspace (../../package.sgl) — member packages ;; do not declare their own version. sigil: "0.20.1" sigil: "0.20.3" description: "Slate — a live, hackable Sigil environment" authors: (list "David Wilson <david@daviwil.com>") entry: '(slate main) ;; app with `sigil build slate --config web`; `sigil test slate` uses `dev`. dependencies: (list ;; Resolve this first: Sigil workspace dependencies share one runtime. ;; Keep independent read/write waiters on the same socket until the next Sigil release. (from-git url: "codeberg:sigil/sigil" package: "sigil-stdlib" commit: "9fdbd7aff420e65c8c2c73f0f97abf8dbfae2d95") ;; (sigil language), used below by Slate's alternate-language support, ;; landed after v0.18.3 and shipped in v0.18.6, so the commit pin this ;; entry carried is retired and it resolves by range like its siblings. (from-git url: "codeberg:sigil/sigil" package: "sigil-wasm-runtime" version: "^0.20") (from-git url: "codeberg:sigil/sigil" package: "sigil-stdlib" version: "^0.20") (from-git url: "codeberg:sigil/sigil" package: "sigil-wasm-runtime" version: ">=0.20") ;; Sigil syntax highlighting: the positioned tokenizer that backs `sigil ;; format` / paren inference. Its `tokenize` is pure string->tokens (no ;; fs/io on that path); library-level (sigil io)/(sigil fs) imports resolve ;; via sigil-stdlib, already in the wasm build. (Phase-1 highlighting.) (from-git url: "codeberg:sigil/sigil" package: "sigil-format" version: "^0.20") (from-git url: "codeberg:sigil/sigil" package: "sigil-format" version: ">=0.20") ;; Markdown → sxml for the rendered markdown view. Pure Sigil (no native); ;; markdown->sxml is a string->sxml transform used by (slate modes markdown-view). (from-git url: "codeberg:sigil/sigil-markdown" package: "sigil-markdown" version: "^0.9.1") ;; children, so the per-render focus re-seat band-aid is gone from ;; index.html and focus correctness DEPENDS on this version. (^0.15.1 ;; brought the on-*: closure event wiring the keyboard model rides on.) ;; 0.15.4 adds lazy memoized subtrees for bounded diff row groups. (from-git url: "codeberg:sigil/sigil-web-client" package: "sigil-web-client" version: "^0.15.4") (from-git url: "codeberg:sigil/sigil" package: "sigil-wasm-dom" version: "^0.20") (from-git url: "codeberg:sigil/sigil" package: "sigil-wasm-dom" version: ">=0.20") ;; Browser API bridge: localStorage (the offline, zero-infra file provider for ;; dired + find-file — the `localstorage` provider's real backing store). Its ;; wasm bridge (assets/sigil-browser.js, global SigilBrowser) is copied into the ;; web build and wired in index.html alongside the dom/net bridges. (from-git url: "codeberg:sigil/sigil" package: "sigil-browser" version: "^0.20") (from-git url: "codeberg:sigil/sigil" package: "sigil-browser" version: ">=0.20") ;; Live-inspect transport: the enclave-rpc browser client rides ;; sigil-irc-websocket -> sigil-websocket -> sigil-wasm-net (the browser ;; WebSocket bridge). enclave-rpc is a package in the enclave monorepo, ;; tagged at the monorepo version — first published at v0.3.0, so ^0.3. (from-git url: "codeberg:sigil/enclave" package: "enclave-rpc" version: "^0.3") (from-git url: "codeberg:sigil/sigil-irc" package: "sigil-irc" version: "^0.16") (from-git url: "codeberg:sigil/sigil-irc" package: "sigil-irc-websocket" version: "^0.16") (from-git url: "codeberg:sigil/sigil-irc" package: "sigil-irc" version: "^0.16.5") (from-git url: "codeberg:sigil/sigil-irc" package: "sigil-irc-websocket" version: "^0.16.5") ;; ^0.10.7, AND THE PATCH DIGIT IS LOAD-BEARING. `(slate providers ;; enclave-node)`'s stream pump calls `ws-receive-nowait` EXCLUSIVELY, which ;; is what Fable's 2026-08-12 binding rule requires of a gated leg. That ;; symbol exists in 0.10.6 — but 0.10.6's version is BUFFER-ONLY, draining a ;; buffer that nothing ever fills, so a nowait-exclusive caller goes DEAF ;; rather than failing loudly. The one non-blocking socket read that makes it ;; usable landed in 0.10.7. A floor of ^0.10.6 would name a version this ;; package silently does not work on, which is worse than naming none. (from-git url: "codeberg:sigil/sigil-websocket" package: "sigil-websocket" version: "^0.10.7") (from-git url: "codeberg:sigil/sigil" package: "sigil-wasm-net" version: "^0.20") ;; usable landed in 0.10.7; complete native bulk-frame writes landed in ;; 0.10.8; bounded native connection setup landed in 0.10.9. Version ;; 0.10.13 also completes TLS frame writes under backpressure, preventing ;; truncated remote file and image transfers. (from-git url: "codeberg:sigil/sigil-websocket" package: "sigil-websocket" version: "^0.10.13") (from-git url: "codeberg:sigil/sigil" package: "sigil-wasm-net" version: ">=0.20") ;; sigil-wire: the binary value codec. The `node:` provider decodes the ;; daemon's bulk (0x10) frames — fs/read-dir listings + fs/read-file bodies ;; arrive as wire-encoded bytes on the node WS. Pure Scheme (no native/C), ;; so it links into the wasm bytecode build unchanged. (from-git url: "codeberg:sigil/sigil-wire" package: "sigil-wire" version: "^0.1") ;; Direct-carrier requests emit structured, secret-free route diagnostics. ;; This is a production import of (sigil log), so the web linker must see ;; the dependency directly rather than inheriting it from another workspace ;; package that is not in the PWA closure. (from-git url: "codeberg:sigil/sigil-log" package: "sigil-log" version: "^0.17") ;; sigil-vt: the native VT/ANSI core. (slate term) is a thin façade over it ;; (the M2 cutover). Native library (int32[4] grid + C parser) — links into ;; both the native test harness and the wasm build (wasm-bridge). (from-git url: "codeberg:sigil/sigil-doc" package: "sigil-doc" version: "^0.1.3")) dev-dependencies: (list (from-git url: "codeberg:sigil/sigil" package: "sigil-test" version: "^0.20") (from-git url: "codeberg:sigil/sigil" package: "sigil-test-runner" version: "^0.20")) (from-git url: "codeberg:sigil/sigil" package: "sigil-test" version: ">=0.20") (from-git url: "codeberg:sigil/sigil" package: "sigil-test-runner" version: ">=0.20")) tasks: (list (task (compile-sigil-modules sources: "src/**/*.sgl") (link-web-application name: "slate") name: "slate" ;; Slate owns its complete PWA shell in assets/index.html. Without ;; manual mode the linker also writes a generic index.html, racing ;; copy-assets and making the final loading page nondeterministic. html: 'manual) (copy-assets source-dir: "assets")))))packages/slate/scripts/deploy-pwa-r2.shmodified
# Deterministic order; upload slate.wasm and sw.js LAST so a visitor never sees# a new shell pointing at not-yet-uploaded assets mid-deploy.mapfile -t files < <(cd "$web_dir" \ && find . -type d -name native -prune -o -type f \ && find . -type d \( -name native -o -name .sigil-package-cache \) -prune -o -type f \ ! -name '*.suspend' ! -name '*.o' ! -name '*.native-codegen.key' \ -printf '%P\n' \ | grep -vxE 'slate.wasm|sw.js' | sort; echo slate.wasm; echo sw.js)packages/slate/sigil.lockmodified
(lock (package name: "sigil-wasm-runtime" url: "codeberg:sigil/sigil" ref: "^0.19" sha: "fe8ff55d07455c78796a7f172f18549ee771c7d8" ref: "^0.20.3" sha: "2e452e2f641fcf63be4e88a2748b34791543be8f" package-selector: "sigil-wasm-runtime" version: "0.19.3") version: "0.20.3") (package name: "sigil-lib" url: "codeberg:sigil/sigil" ref: "^0.19" sha: "fe8ff55d07455c78796a7f172f18549ee771c7d8" sha: "2e452e2f641fcf63be4e88a2748b34791543be8f" package-selector: "sigil-lib" version: "0.19.3") version: "0.20.3") (package name: "sigil-stdlib" url: "codeberg:sigil/sigil" ref: "^0.19" sha: "fe8ff55d07455c78796a7f172f18549ee771c7d8" ref: "^0.20.3" sha: "2e452e2f641fcf63be4e88a2748b34791543be8f" package-selector: "sigil-stdlib" version: "0.19.3") version: "0.20.3") (package name: "sigil-format" url: "codeberg:sigil/sigil" ref: "^0.19" sha: "fe8ff55d07455c78796a7f172f18549ee771c7d8" ref: "^0.20.3" sha: "2e452e2f641fcf63be4e88a2748b34791543be8f" package-selector: "sigil-format" version: "0.19.3") version: "0.20.3") (package name: "sigil-markdown" url: "codeberg:sigil/sigil-markdown" ref: "^0.9.1" (package name: "sigil-web-client" url: "codeberg:sigil/sigil-web-client" ref: "^0.15.2" sha: "1b039fe62d7e51d70bee20b10830e9e064b2da01" sha: "0d608eff6a1ba2150ca3e35a1e79a7f0eea74a13" package-selector: "sigil-web-client" version: "0.15.3") version: "0.15.4") (package name: "sigil-wasm-dom" url: "codeberg:sigil/sigil" ref: "^0.19" sha: "fe8ff55d07455c78796a7f172f18549ee771c7d8" ref: "^0.20.3" sha: "2e452e2f641fcf63be4e88a2748b34791543be8f" package-selector: "sigil-wasm-dom" version: "0.19.3") version: "0.20.3") (package name: "sigil-browser" url: "codeberg:sigil/sigil" ref: "^0.19" sha: "fe8ff55d07455c78796a7f172f18549ee771c7d8" ref: "^0.20.3" sha: "2e452e2f641fcf63be4e88a2748b34791543be8f" package-selector: "sigil-browser" version: "0.19.3") version: "0.20.3") (package name: "enclave-rpc" url: "codeberg:sigil/enclave" ref: "^0.3" version: "0.16.4") (package name: "sigil-websocket" url: "codeberg:sigil/sigil-websocket" ref: "^0.10.7" sha: "6c181e52c71a2838980fc376e1e062176ee9dd45" ref: "^0.10.8" sha: "be7e261e4dfe799821132a7fcde9cc5f46bfa706" package-selector: "sigil-websocket" version: "0.10.7") version: "0.10.8") (package name: "sigil-wasm-net" url: "codeberg:sigil/sigil" ref: "^0.19" sha: "fe8ff55d07455c78796a7f172f18549ee771c7d8" ref: "^0.20.3" sha: "2e452e2f641fcf63be4e88a2748b34791543be8f" package-selector: "sigil-wasm-net" version: "0.19.3") version: "0.20.3") (package name: "sigil-wire" url: "codeberg:sigil/sigil-wire" ref: "^0.1" version: "0.1.4") (package name: "sigil-test" url: "codeberg:sigil/sigil" ref: "^0.19" sha: "fe8ff55d07455c78796a7f172f18549ee771c7d8" ref: "^0.20.3" sha: "2e452e2f641fcf63be4e88a2748b34791543be8f" package-selector: "sigil-test" version: "0.19.3") version: "0.20.3") (package name: "sigil-test-runner" url: "codeberg:sigil/sigil" ref: "^0.19" sha: "fe8ff55d07455c78796a7f172f18549ee771c7d8" ref: "^0.20.3" sha: "2e452e2f641fcf63be4e88a2748b34791543be8f" package-selector: "sigil-test-runner" version: "0.19.3") version: "0.20.3") (package name: "sigil-json" url: "codeberg:sigil/sigil-json" ref: "^0.16.0"packages/slate/src/slate/accessory.sglmodified
;;; retires that JS shim).(define-library (slate accessory) (import (sigil core) (sigil dict) (import (sigil dict) (slate view) (slate mode) (slate components)) (export accessory-bar resolve-actions actions-resolve action-resolve action-resolve-from action-trace->text (export accessory-bar accessory-keyboard-deck resolve-actions actions-resolve action-resolve action-resolve-from action-trace->text frame-categories spec->props action-reference-valid? tier-of sort-by-tier accessory-category-ids) #{ id: 'hist-back label: "◀ Layout" command: "pane-history-back" tier: 'tertiary } #{ id: 'hist-forward label: "Layout ▶" command: "pane-history-forward" tier: 'tertiary } ;; mirror the bar for the other thumb (design §5) #{ id: 'flip-thumb label: "⇄ Hand" command: "flip-thumb-side" tier: 'tertiary }) })) #{ id: 'flip-thumb label: "⇄ Hand" command: "flip-thumb-side" tier: 'tertiary }) } #{ id: 'help label: "Help" glyph: "?" tier: 'tertiary items: (list #{ id: 'key-bindings label: "Key Bindings" key: "C-?" tier: 'tertiary }) })) ;; ---- render: the two-row ribbon (design §4, basic form) ------------------ ;; A category row (glyph-first chips; the active chip expands to glyph+label, ((dict-ref h command: #f) (list `(data-slate-hold-cmd ,(dict-ref h command: "")))) (else '())))) ;; Action behavior is late-bound data. Re-registering a command must update ;; every button that names it, so an item carries exactly one command name or ;; key spec—not a captured procedure. The same rule applies to hold:. ;; every button that names it, so an item carries exactly one command name, ;; key spec, or host action. Host actions keep browser capabilities (such as ;; clipboard writes) inside the originating user gesture. (define (action-reference-valid? it) (let* ((cmd (dict-ref it command: #f)) (key (dict-ref it key: #f)) (host (dict-ref it host-action: #f)) (hold (dict-ref it hold: #f)) (hold-cmd (and hold (dict-ref hold command: #f))) (hold-key (and hold (dict-ref hold key: #f)))) (and (or (and (string? cmd) (not key)) (and (string? key) (not cmd))) (and (or (and (string? cmd) (not key) (not host)) (and (string? key) (not cmd) (not host)) (and (string? host) (not cmd) (not key) (not hold))) (or (not hold) (and (dict? hold) (or (and (string? hold-cmd) (not hold-key)) (define (action-btn it) (let ((cmd (dict-ref it command: #f)) (key (dict-ref it key: #f)) (host (dict-ref it host-action: #f)) (label (dict-ref it label: ""))) (unless (action-reference-valid? it) (error 'accessory "action behavior must be exactly one command: name or key: spec")) (error 'accessory "action behavior must be exactly one command:, key:, or host-action:")) (cond (host `(button (@ (class ,(btn-class it)) (type "button") (data-slate-host-action ,host)) ,label)) (cmd `(div (@ (class ,(btn-class it)) (data-slate-click ,cmd) ,@(hold-attrs it)) ,label)) ;; a repeat: item carries data-slate-repeat so the JS press-and-hold ;; auto-repeats it (design §13); the tap path is unchanged. hold: adds a (let ((live? (or (not (eq? (dict-ref sticky ctrl: 'off) 'off)) (not (eq? (dict-ref sticky alt: 'off) 'off))))) `(div (@ (class ,(string-append "acc-kbd-chip" (if open? " open" "") (if live? " live" ""))) (data-slate-click "acc-kbd-menu-toggle")) ;; Direct OSK toggle: the fixed key deck replaced the popup. (data-kbd-toggle "1")) "⌨"))) ;; The directional cluster, to the RIGHT of [C]/[M] in the SAME row (David, ;; 2026-08-11: "directional keys to the right of those keys so that they can (div (@ (class "acc-kbd-arrows")) ,@(map action-btn kbd-arrow-items))))) ;; The keyboard deck is infrastructure, not an action category. Keep its ;; complete two-row layout visible between the Ledge and system keyboard. (define accessory-key-row-1 (list #{ label: "ESC" key: "Escape" } #{ label: "/" key: "/" } #{ label: "-" key: "-" } #{ label: "HOME" key: "Home" repeat: #t } #{ label: "↑" key: "ArrowUp" repeat: #t } #{ label: "END" key: "End" repeat: #t } #{ label: "PGUP" key: "PageUp" repeat: #t })) (define accessory-key-row-2 (list #{ label: "TAB" key: "Tab" } #{ label: "CTRL" modifier: "ctrl" } #{ label: "ALT" modifier: "alt" } #{ label: "←" key: "ArrowLeft" repeat: #t } #{ label: "↓" key: "ArrowDown" repeat: #t } #{ label: "→" key: "ArrowRight" repeat: #t } #{ label: "PGDN" key: "PageDown" repeat: #t })) (define (accessory-key-button it sticky) (let ((mod (dict-ref it modifier: #f))) (if mod `(div (@ (class ,(string-append "term-key term-key-mod " (symbol->string (dict-ref sticky (if (string=? mod "ctrl") ctrl: alt:) 'off)))) (data-slate-click ,(string-append "sticky-mod:" mod))) ,(dict-ref it label: "")) `(div (@ (class "term-key") (data-slate-key ,(dict-ref it key: "")) ,@(if (dict-ref it repeat: #f) '((data-slate-repeat "1")) '())) ,(dict-ref it label: ""))))) (define (accessory-keyboard-deck v sticky . compose-target) (if v `(div (@ (class "accessory-key-deck") (key "accessory-key-deck") (data-compose-target ,(if (pair? compose-target) (or (car compose-target) "") ""))) (div (@ (class "terminal-key-page") (key "keys")) (div (@ (class "terminal-key-rows")) (div (@ (class "terminal-key-row")) ,@(map (lambda (it) (accessory-key-button it sticky)) accessory-key-row-1)) (div (@ (class "terminal-key-row")) ,@(map (lambda (it) (accessory-key-button it sticky)) accessory-key-row-2)))) (div (@ (class "terminal-compose-page") (key "compose")) (button (@ (class "terminal-compose-back") (type "button") (aria-label "Show terminal keys")) "❮") ;; The browser owns this draft and its IME selection. Never ;; render a value: unrelated output must not rewrite it. (textarea (@ (class "terminal-compose-input") (key "terminal-compose-input") (rows "2") (aria-label "Compose terminal text") (placeholder "Compose text… Enter sends text only") (autocorrect "on") (autocapitalize "sentences") (autocomplete "off") (spellcheck "true") (enterkeyhint "send"))) (button (@ (class "terminal-compose-send") (type "button") (aria-label "Send text without Enter")) "Send") (div (@ (class "terminal-compose-history") (key "history") (role "group") (aria-label "Recent terminal text") (hidden "hidden"))) (span (@ (class "terminal-compose-status") (role "status"))))) "")) (define (bar-class active? thumb-side) (string-append "accessory-bar" (if active? "" " collapsed") (let ((active (find-active cats active-cat))) `(div (@ (class ,(bar-class active thumb-side))) ;; the ⌨ menu popover (when open) floats above the bar. ,@(if kbd-menu-open? (list (kbd-menu sticky)) '()) ;; The fixed deck below the Ledge replaces the old ;; floating keyboard/modifier popover. ,@(if active (list `(div (@ (class "acc-actions")) ,@(map action-btn (sort-by-tier (dict-ref active items: '()))))) (list `(div (@ (class "acc-actions") (key ,(string-append "acc-actions-" (symbol->string (cat-id active)))) (data-accessory-category ,(symbol->string (cat-id active)))) ,@(map action-btn (sort-by-tier (dict-ref active items: '()))) ;; Explicit browser-owned status slot: an ;; unmanaged sibling can outlive this row ;; when the menu collapses or changes. (span (@ (class "acc-host-status") (key "host-status") (role "status"))))) '()) ;; category row: the ⌨ chip (pinned thumb-near — opens the ;; keyboard/sticky menu), the category chips, then the view ;; switcher pinned at the thumb-FAR end (§6 + §8). (div (@ (class "acc-cats")) ,(kbd-chip sticky kbd-menu-open?) (div (@ (class "acc-cats") (key "acc-cats")) ,(kbd-chip sticky #f) ,@(map (lambda (c) (cat-chip c (and active (eq? (cat-id c) (cat-id active))))) cats)packages/slate/src/slate/agent/remote.sglmodified
;;; One stream per node multiplexes requests and live subscriptions.(define-library (slate agent remote) (import (sigil io) (sigil json) (sigil websocket) (sigil time) (slate providers enclave-node) (slate enclave-account session)) (slate enclave-account session)) (export agent-remote-request! agent-remote-poll! agent-remote-on-event! agent-remote-ready? agent-remote-reset!) agent-remote-ready? agent-remote-reset! agent-remote-configure!) (begin ;; The Enclave provider also uses the local node provider. Inject its ;; ticket transport to avoid node -> agent -> Enclave -> node imports. (define *ticket-request* (lambda (host ready failed) (failed "Agent transport is not configured"))) (define *retry-delay* (lambda (attempt) 1000)) (define (agent-remote-configure! ticket-request retry-delay) (set! *ticket-request* ticket-request) (set! *retry-delay* retry-delay)) (define *nodes* #{}) (define *next-id* 0) (define *account-generation* #f) ;; waiting for data. Its next connection must refresh those views. (vector-set! node 7 #t) (vector-set! node 5 (+ (current-second) (/ (reattach-backoff-ms (vector-ref node 6)) 1000))) (/ (*retry-delay* (vector-ref node 6)) 1000))) (vector-set! node 6 (+ 1 (vector-ref node 6))) (when ws (ws-close ws)) (for-each (vector-set! node 2 'ticket) (vector-set! node 8 (current-second)) (let ((generation (vector-ref node 11))) (enclave-node-stream-ticket! host (*ticket-request* host (lambda (ticket url) (when (and (eq? node (dict-ref *nodes* host #f)) (= generation (vector-ref node 11))packages/slate/src/slate/bindings.sglmodified
;; C-c to interrupt, …). That is the correct split. "C-c" #{} "C-x" #{ "3" "split-right" "g" "git-status" "2" "split-down" "1" "delete-other-windows" "o" "other-pane"packages/slate/src/slate/boot.sglmodified
;; terminal: on a tmux-less node the ladder returns a marked downgrade ;; carrying a sentence, and every caller renders it. slate/terminals-use-tmux: #t slate/terminal-predictive-echo: #f ;; Client-side Screen Wake Lock intent. Off by default; the browser ;; bridge acquires it only while this document is visible. slate/keep-screen-awake: #f ;; Focus-follows-mouse: hovering a pane focuses it. Global toggle, default ;; ON (desktop); the JS hover handler no-ops on touch. Persisted + boot- ;; restored; init.sgl-settable. slate/face-overrides: #{} slate/echo: "ready" ;; Echo lifecycle (slate-ux-sweep #2): echo messages auto-clear + fade. ;; slate/echo-age counts slate-echo-tick ticks since the current message ;; was written (with-echo resets it to 0); slate/echo-fading? flags the ;; ledge-echo to run its CSS fade-out animation once the linger elapses. ;; Legacy lifecycle fields remain in the state shape for init/state ;; compatibility; the browser now owns dismissal with one CSS animation. slate/echo-age: 0 slate/echo-fading?: #f ;; Monotonic identity for the host-owned echo dismissal animation. ;; Repeating the same text still restarts its full visible lifetime. slate/echo-generation: 0 ;; The Ledge is DATA: a LIST of ledge-item records projected into the ;; bottom strip (mirrors Emacs's mode-line-format-as-a-list). The ;; built-ins (space chip, echo, clock, ≡ glyph, connection dot) are justpackages/slate/src/slate/commands/appearance.sglmodified
(lambda (s) (toggle s slate/translucent: #f "transparency "))) (exact "terminals-use-tmux:toggle" "Terminal: Toggle Tmux" "toggle tmux for newly opened terminals" tmux-toggle) (exact "terminal-predictive-echo:toggle" "Terminal: Toggle Predictive Echo" "preview simple typing while waiting for terminal output" (lambda (s) (toggle s slate/terminal-predictive-echo: #f "terminal predictive echo "))) (exact "opacity:dec" "Content Opacity: Decrease" "more see-through" (lambda (s) (amount-adjust s "dec" slate/opacity: 10 100 5 (content-opacity s) "content opacity" "%")))packages/slate/src/slate/commands/folder.sglmodified
;;; (slate commands folder) — folder navigation, marking, and provider operations.(define-library (slate commands folder) (import (sigil core) (slate command) (slate findfile) (slate foldernav) (slate modes folder) (slate list) (slate prompt) (slate prompt candidates) (slate provider) (slate providers slate-meta) (slate root) (slate space) (import (slate command) (slate findfile) (slate foldernav) (slate modes folder) (slate list) (slate pane) (slate prompt) (slate prompt candidates) (slate provider) (slate providers localstorage) (slate providers slate-meta) (slate root) (slate space) (slate state) (slate view)) (export install-folder-commands! configure-folder-commands! kickoff-folder-load! open-folder-reusing) k)) "folder-pattern" (list "folder-pattern"))) (define *folder-loads* '()) (define (kickoff-folder-load! source view-name) (let ((async-op (provider-async-op source))) (when async-op (async-op source (lambda (entries) ((required set-state:) (update-view ((required state:)) view-name (lambda (v) (if (folder? v) (folder-set-entries v entries) v)))) ((required refresh:))) (lambda (err) ((required set-state:) (with-echo ((required state:)) "folder load failed")) ((required refresh:))))))) (let* ((ticket (vector #t)) (pending? (lambda () (let ((found (assoc view-name *folder-loads*))) (and found (eq? (cdr found) ticket))))) (forget! (lambda () (set! *folder-loads* (filter (lambda (pair) (not (string=? (car pair) view-name))) *folder-loads*))))) (forget!) (set! *folder-loads* (cons (cons view-name ticket) *folder-loads*)) (async-op source (lambda (entries) (when (pending?) (forget!) ((required set-state:) (update-view ((required state:)) view-name (lambda (v) (if (and (folder? v) (equal? source (view-source v))) (folder-set-entries v entries) v)))) ((required refresh:)))) (lambda (err) (when (pending?) (forget!) ((required set-state:) (with-echo (update-view ((required state:)) view-name (lambda (v) (when (and (folder? v) (equal? source (view-source v))) (folder-detail-set! v load-error: #t)) v)) "folder load failed")) ((required refresh:))))))))) (define (open-folder-reusing state from-view source keep?) (let* ((opened ((required open-source:) state source)) (new-name (source-root-display-name source)) (let* ((new-name (source-root-display-name source)) (opened (update-view ((required open-source:) state source) new-name (lambda (v) (folder-inherit-settings from-view v)))) (from-folder? (and from-view (folder? from-view) #t))) (if (and from-folder? (reuse-drops-from? (begin (ff-cache-reset!) ((required drill-query:) (dict-set state slate/findfile-provider: (view-fs-provider state)) "findfolder" (view-default-directory state))))) "findfolder" (dir-path (view-default-directory state)))))) (define (folder-mark-pattern-command state) (let ((view (active-view state))) (define (folder-open-index-command state suffix) (let ((index (string->number suffix)) (view (active-view state))) (if (and (folder? view) index) (if (and (folder? view) (integer? index) (>= index 0) (< index (folder-count view))) (let* ((moved (folder-set-point view index)) (next (update-active state (lambda (_) moved))) (entry (folder-entry-at moved))) (if entry ((required open-source:) next (folder-child-source moved entry)) next)) (if entry (folder-open-entry-command next) next)) state))) (define (folder-rename-command state) (when (= remaining 0) (all-error failed))))) sources)) (if (= (length sources) 1) "restored entry" "restored marked entries")))))) (define (folder-select-index-command state suffix) (let ((index (string->number suffix))) (if (and (integer? index) (>= index 0)) (on-folder state (lambda (v) (folder-set-point v index))) state))) (define (folder-mark-index-command state suffix) (let* ((index (string->number suffix)) (v (active-view state))) (if (not (and (folder? v) (integer? index) (>= index 0) (< index (folder-count v)))) state (on-folder state (lambda (v) (let* ((point (folder-point v)) (at (folder-set-point v index)) (marked? (folder-marked? at (folder-entry-at at)))) (folder-set-point (if marked? (folder-unmark at) (folder-mark at)) point))))))) (define (install-folder-commands!) (for-each (lambda (entry) (exact (car entry) (cadr entry) (cadr entry) (lambda (state) (on-folder state (caddr entry))))) (list (list "folder-preview" "Folder: Toggle Preview" folder-toggle-preview) (list "folder-hidden" "Folder: Hidden" folder-toggle-hidden) (list "folder-compact" "Folder: Compact Rows" folder-toggle-compact) (list "folder-sort-name" "Folder: Sort by Name" (lambda (v) (folder-set-sort v name:))) (list "folder-sort-size" "Folder: Sort by Size" (lambda (v) (folder-set-sort v size:))) (list "folder-sort-newest" "Folder: Most Recently Modified" (lambda (v) (folder-set-sort v newest:))) (list "folder-sort-oldest" "Folder: Least Recently Modified" (lambda (v) (folder-set-sort v oldest:))))) (install-builtin-command-family! "folder-select-index:" "select a directory entry" source-module folder-select-index-command) (install-builtin-command-family! "folder-mark-index:" "toggle an entry mark" source-module folder-mark-index-command) (exact "open-folder" "Folder: Open" "open a folder" open-folder-command) (exact "folder-next" "Folder: Next" "select next entry" (lambda (s) (on-folder s (lambda (v) (folder-move v 1))))) (exact "folder-prev" "Folder: Previous" "select previous entry" (lambda (s) (on-folder s (lambda (v) (folder-move v -1)))))packages/slate/src/slate/commands/git.sgladded
;;; (slate commands git) — commands shared by Git status keys and controls.(define-library (slate commands git) (import (sigil core) (sigil string) (slate command) (slate commands git branch) (slate commands project) (slate components diff) (slate git model) (slate git status-view) (slate git review) (slate git diff-model) (slate git diff-view) (slate git log-model) (slate git log-view) (slate extend) (slate mode) (slate state) (slate view)) (export install-git-commands! configure-git-commands!) (begin (define source-module '(slate commands git)) (define *ops* #{}) (define (op key) (or (dict-ref *ops* key #f) (error 'git-command "Git command op is not configured"))) (define (configure-git-commands! ops) (set! *ops* ops) (configure-git-branch-commands! ops) #t) (define (status-view? v) (and v (git-status-model v) #t)) (define (diff-view? v) (and v (git-diff-model v) #t)) (define (git-view? v) (or (status-view? v) (diff-view? v))) (define (make-status-view source model) (let* ((desc (mode-descriptor 'git-status-mode)) (ctor (and (dict? desc) (dict-ref desc ctor: #f)))) (if ctor (ctor slate/name: (git-status-view-name source) slate/source: source slate/git-model: model slate/git-row-cache: (make-git-status-row-cache) slate/git-selected: #f slate/git-folded: (list "section:recent-commits") slate/git-inline-models: '() slate/git-hunk-folded: '()) (error 'git-status "Git status mode is not installed")))) (define (make-diff-view source model name commit) (let* ((desc (mode-descriptor 'git-diff-mode)) (ctor (and (dict? desc) (dict-ref desc ctor: #f)))) (if ctor (ctor slate/name: name slate/source: source slate/git-diff-model: model slate/git-selected: #f slate/git-commit: commit) (error 'git-diff "Git diff mode is not installed")))) (define (log-view? v) (and v (git-log-model v) #t)) (define (open-log state) (let* ((v (active-view state)) (repo (cond ((status-view? v) (dict-ref (dict-ref (git-status-model-state (git-status-model v)) data: #{}) repo: #f)) ((diff-view? v) (git-diff-model-repo (git-diff-model v))) (else #f))) (name (and repo (git-log-view-name repo)))) (if (not name) state (if (find-view (views state) name) (focus-view state name) (let* ((source (view-source v)) (model (make-git-log-model source repo (lambda (ignored) ((op refresh:))))) (desc (mode-descriptor 'git-log-mode)) (ctor (and (dict? desc) (dict-ref desc ctor: #f))) (next (ensure-view state name (lambda () (ctor slate/name: name slate/source: source slate/git-log-model: model slate/git-selected: #f slate/git-return-view: (view-name v)))))) ((op set-state:) next) (git-log-model-load! model) ((op state:))))))) (define (on-log state fn) (let ((v (active-view state))) (if (log-view? v) (fn state v (git-log-model v)) state))) (define (log-focus state region) (on-log state (lambda (state v model) (update-active state (lambda (v) (dict-set v slate/git-log-focus: region)))))) (define (log-scroll amount) (lambda (state) (on-log state (lambda (state v model) (if (eq? (dict-ref v slate/git-log-focus: 'list) 'review) (update-active state (lambda (v) (dict-set (dict-set v slate/git-log-scroll: amount) slate/git-log-scroll-revision: (+ 1 (dict-ref v slate/git-log-scroll-revision: 0))))) state))))) (define (log-move delta) (lambda (state) (on-log state (lambda (state v model) (if (eq? (dict-ref v slate/git-log-focus: 'list) 'review) ((log-scroll (if (> delta 0) "line-down" "line-up")) state) (update-active state (lambda (v) (git-log-move v delta)))))))) (define (log-select state oid) (on-log state (lambda (state v model) (if (find (lambda (commit) (equal? oid (dict-ref commit oid: #f))) (git-log-commits model)) (update-active state (lambda (v) (dict-set v slate/git-selected: oid))) state)))) (define (log-load! model oid) ;; Moving focus or clicking the current commit must retain its patch and ;; scroll position. An explicit retry still works after either load fails. (let* ((review (or (dict-ref (git-log-model-state model) review: #f) #{})) (diff (dict-ref review diff: #f))) (unless (and (equal? oid (dict-ref review oid: #f)) (not (eq? (dict-ref review state: #f) error:)) (not (and diff (eq? (dict-ref (git-diff-model-state diff) state: #f) error:)))) (git-log-model-inspect! model oid)))) (define (log-open state oid) (on-log state (lambda (state v model) (if (find (lambda (commit) (equal? oid (dict-ref commit oid: #f))) (git-log-commits model)) (begin (log-load! model oid) (log-focus (log-select state oid) 'list)) state)))) (define (log-inspect-oid state oid) (on-log state (lambda (state v model) (log-load! model oid) (log-focus state 'review)))) (define (log-inspect state) (on-log state (lambda (state v model) (let ((commit (git-log-selected v))) (when commit (log-load! model (dict-ref commit oid: #f)))) (log-focus state 'review)))) (define (log-switch-pane state) (on-log state (lambda (state v model) (if (eq? (dict-ref v slate/git-log-focus: 'list) 'review) (log-focus state 'list) (log-inspect state))))) (define (log-page direction) (lambda (state) (on-log state (lambda (state v model) (git-log-model-page! model direction) (log-focus state 'list))))) (define (log-refresh state) (on-log state (lambda (state v model) (unless (eq? (dict-ref (git-log-model-state model) state: #f) loading:) (git-log-model-load! model)) state))) (define (log-copy review?) (lambda (state) (on-log state (lambda (state v model) (let* ((review (dict-ref (git-log-model-state model) review: #f)) (commit (if review? (and review (dict-ref review commit: #f)) (git-log-selected v))) (oid (and commit (dict-ref commit oid: #f)))) (if oid (dict-set (dict-set state slate/kill: oid) slate/clip-stage: oid) state)))))) (define (log-diff-more state) (on-log state (lambda (state v model) (let* ((review (dict-ref (git-log-model-state model) review: #f)) (diff (and review (dict-ref review diff: #f)))) (when diff (git-diff-model-more! diff))) state))) (define (log-quit state) (on-log state (lambda (state v model) (git-log-model-dispose! model) (let* ((name (dict-ref v slate/git-return-view: #f)) (next ((op close-view:) state))) (if (and name (find-view (views next) name)) (focus-view next name) next))))) (define (on-git state fn) (update-active state (lambda (v) (if (git-view? v) (fn v) v)))) ;; Discovery is asynchronous, so the loading shell initially has a ;; source-qualified provisional name. As soon as the node supplies its ;; canonical repository identity, rename that shell or discard it in favor ;; of the already-open canonical view. The model object is the stable ;; handle across that rename. (define (view-for-model state model) (let loop ((rest (views state))) (cond ((null? rest) #f) ((eq? (git-status-model (car rest)) model) (car rest)) (else (loop (cdr rest)))))) (define (canonicalize-view state model data) (let* ((current (view-for-model state model)) (old-name (and current (view-name current))) (name (git-status-repository-view-name (dict-ref data repo: #{}))) (existing (and name (find-view (views state) name)))) (cond ((or (not current) (not name) (string=? old-name name)) state) (existing (focus-view (dict-set state slate/views: (filter (lambda (v) (not (eq? (git-status-model v) model))) (views state))) name)) (else (focus-view (update-view state old-name (lambda (v) (dict-set v slate/name: name))) name))))) (define (selected-owner v) (let ((row (git-status-selected-row v))) (and row (dict-ref row owner: (and (eq? (dict-ref row kind: #f) entry:) (dict-ref row id: #f)))))) (define (resume-selected! v) (let ((model (inline-model v (selected-owner v)))) (when model (git-diff-model-resume! model))) v) (define (accept-status! model data mutated? changed-id) (let* ((state ((op state:))) (v (view-for-model state model))) (when v (let* ((old-data (dict-ref v slate/git-data: #{})) (changed (find (lambda (entry) (equal? changed-id (dict-ref entry id: #f))) (as-list (dict-ref old-data entries: '())))) (path (and changed (entry-path changed))) (next (git-review-status v data)) (models (dict-ref next slate/git-inline-models: '())) (repo (dict-ref data repo: #{})) (snapshot (dict-ref data snapshot: #{}))) ;; Publish new identities before diff listeners run. A closed ;; review must never be resurrected by a late page. ((op set-state:) (canonicalize-view (update-view state (view-name v) (lambda (ignored) next)) model data)) (for-each (lambda (pair) (unless (assoc (car pair) models) (git-diff-model-invalidate! (cdr pair)))) (dict-ref v slate/git-inline-models: '())) (for-each (lambda (pair) (let ((diff (cdr pair))) (when (or (not mutated?) (not (git-snapshot=? (git-diff-model-snapshot diff) snapshot))) (git-diff-model-defer! diff repo snapshot) (when (or (not mutated?) (equal? path (git-diff-model-target diff)) (equal? (car pair) (selected-owner next))) (git-diff-model-resume! diff))))) models))))) (define (inline-listener status-model id diff) (let ((previous (git-diff-model-state diff))) (lambda (state) (let ((old previous) (v (view-for-model ((op state:)) status-model))) (set! previous state) (when (and v (eq? diff (inline-model v id))) (when (and (eq? (dict-ref state state: #f) ready:) (pair? (dict-ref old pages: '()))) ((op set-state:) (update-view ((op state:)) (view-name v) (lambda (current) (git-review-diff current id old))))) ((op refresh:))))))) (define (load-view! source name model) (git-status-model-load! model source (current-project ((op state:)))) ((op refresh:)) #t) (define (open-status state) (let* ((source (view-source (active-view state))) (name (and source (git-status-view-name source)))) (if (not name) state (let ((existing (find-view (views state) name))) (if existing (begin (git-status-model-refresh! (git-status-model existing) source (current-project state)) (focus-view state name)) (letrec ((model (make-git-status-model (lambda (model-state) ;; A pending mutation still contains the old ;; authoritative snapshot. Rendering it makes ;; the diff flash unchanged before the mutation ;; response arrives, which looks like a failed ;; stage/unstage followed by a second refresh. ;; Keep the current frame intact until the node ;; returns either fresh status or an error. (unless (eq? (dict-ref model-state mutation: #f) pending:) (when (eq? (dict-ref model-state state: #f) ready:) (let ((data (dict-ref model-state data: #{}))) (accept-status! model data (eq? (dict-ref model-state mutation: #f) complete:) (dict-ref model-state changed-entry: #f)) (git-status-model-watch! model source (current-project ((op state:))) (dict-ref data repo: #{})))) ((op refresh:))))))) (let ((next (ensure-view state name (lambda () (make-status-view source model))))) ((op set-state:) next) (load-view! source name model) ((op state:))))))))) (define (move delta) (lambda (state) (on-git state (lambda (v) (if (diff-view? v) (git-diff-move v delta) (resume-selected! (git-status-move v delta))))))) (define (inline-model v id) (let ((found (assoc id (dict-ref v slate/git-inline-models: '())))) (and found (cdr found)))) (define (row-by-id v id) (let loop ((rows (git-status-rows v))) (cond ((null? rows) #f) ((string=? id (dict-ref (car rows) id: "")) (car rows)) (else (loop (cdr rows)))))) (define (remove-inline models id) (filter (lambda (pair) (not (string=? (car pair) id))) models)) (define (toggle state) (on-git state (lambda (v) (if (not (status-view? v)) v (let* ((row (git-status-selected-row v)) (kind (and row (dict-ref row kind: #f)))) (cond ((eq? kind section:) (git-status-toggle v)) ((diff-kind=? (diff-row-kind row) hunk:) (let* ((id (dict-ref row id: "")) (folded (dict-ref v slate/git-hunk-folded: '()))) (dict-set v slate/git-hunk-folded: (if (member id folded) (filter (lambda (x) (not (string=? x id))) folded) (cons id folded))))) ((eq? kind entry:) (let* ((id (dict-ref row id: "")) (models (dict-ref v slate/git-inline-models: '())) (existing (inline-model v id))) (if existing (begin (git-diff-model-invalidate! existing) (dict-set v slate/git-inline-models: (remove-inline models id))) (let ((spec (diff-spec v row))) (letrec ((model (make-git-diff-model (view-source v) (dict-ref spec repo: #{}) (dict-ref spec snapshot: #{}) (dict-ref spec kind: "worktree") (dict-ref spec target: #f) (lambda (state) (listener state)))) (listener (inline-listener (git-status-model v) id model))) (git-diff-model-load! model) (dict-set v slate/git-inline-models: (cons (cons id model) models))))))) (else v))))))) (define (refresh state) (let ((v (active-view state))) (if (status-view? v) (begin (git-status-model-refresh! (git-status-model v) (view-source v) (current-project state)) state) state))) (define (select state suffix) (on-git state (lambda (v) (let ((next (dict-set v slate/git-selected: suffix))) (if (status-view? next) (resume-selected! next) next))))) (define (set-layout state suffix) (if (not (member suffix '("auto" "inline" "split"))) state (let ((layout (string->symbol suffix))) (set-option! slate/git-layout: layout) (dict-set state slate/git-layout: layout)))) (define (cycle-layout state) (let ((layout (dict-ref state slate/git-layout: 'auto))) (set-layout state (cond ((eq? layout 'auto) "inline") ((eq? layout 'inline) "split") (else "auto"))))) (define (select-toggle state suffix) (toggle (select state suffix))) (define (diff-spec v row) (let* ((state (git-status-model-state (git-status-model v))) (data (dict-ref state data: #{})) (repo (dict-ref data repo: #{})) (snapshot (dict-ref data snapshot: #{})) (row-kind (dict-ref row kind: #f))) (cond ((eq? row-kind entry:) (let* ((entry (dict-ref row entry: #{}) ) (section (dict-ref row section: unstaged:))) #{ repo: repo snapshot: snapshot kind: (cond ((eq? section staged:) "staged") ((eq? section untracked:) "untracked") (else "worktree")) target: (dict-ref entry path: #f) })) ((and (eq? row-kind section:) (eq? (dict-ref row section: #f) staged:)) #{ repo: repo snapshot: snapshot kind: "staged" target: #f }) ((eq? row-kind commit:) #{ repo: repo snapshot: snapshot kind: "commit" target: (dict-ref (dict-ref row commit: #{}) oid: #f) }) (else #f)))) (define (open-diff state) (let* ((v (active-view state)) (row (and (status-view? v) (git-status-selected-row v))) (spec (and row (diff-spec v row)))) (if (not spec) state (let* ((source (view-source v)) (repo (dict-ref spec repo: #{})) (kind (dict-ref spec kind: "worktree")) (target (dict-ref spec target: #f)) (commit (and (eq? (dict-ref row kind: #f) commit:) (dict-ref row commit: #f))) (name (git-diff-view-name repo kind target)) (existing (and name (find-view (views state) name)))) (if existing (focus-view state name) (letrec ((model (make-git-diff-model source repo (dict-ref spec snapshot: #{}) kind target (lambda (ignored) ((op refresh:)))))) (let ((next (ensure-view state name (lambda () (make-diff-view source model name commit))))) ((op set-state:) next) (git-diff-model-load! model) ((op state:))))))))) (define (open-conflict-stage stage) (lambda (state suffix) (let* ((v (active-view state)) (row (and (status-view? v) (row-by-id v suffix))) (entry (and row (dict-ref row entry: #f))) (data (and v (dict-ref (git-status-model-state (git-status-model v)) data: #{}))) (repo (and data (dict-ref data repo: #{}))) (snapshot (and data (dict-ref data snapshot: #{}))) (path (and entry (entry-path entry)))) (if (not path) state (let* ((target #{ path: path stage: stage }) (name (git-diff-view-name repo "conflict-stage" target)) (model (make-git-diff-model (view-source v) repo snapshot "conflict-stage" target (lambda (ignored) ((op refresh:))))) (next (ensure-view state name (lambda () (make-diff-view (view-source v) model name #f))))) ((op set-state:) next) (git-diff-model-load! model) ((op state:))))))) (define (diff-more state) (let ((v (active-view state))) (when (diff-view? v) (git-diff-model-more! (git-diff-model v))) state)) (define (inline-more state suffix) (let ((v (active-view state))) (when (status-view? v) (let ((model (inline-model v suffix))) (when model (git-diff-model-more! model)))) state)) (define (inline-refresh state suffix) (let* ((v (active-view state)) (model (and (status-view? v) (inline-model v suffix)))) (when model (let ((data (dict-ref (git-status-model-state (git-status-model v)) data: #{}))) (git-diff-model-reload! model (dict-ref data repo: #{}) (dict-ref data snapshot: #{})))) state)) (define (diff-refresh state) (let ((v (active-view state))) (when (diff-view? v) (git-diff-model-load! (git-diff-model v))) state)) (define (visit state) (let* ((v (active-view state)) (row (cond ((status-view? v) (git-status-selected-row v)) ((diff-view? v) (git-diff-selected-row v)) (else #f)))) (cond ((diff-view? v) (let* ((line (git-diff-selected-row v)) (model (git-diff-model v)) (path (git-diff-model-target model)) (repo (git-diff-model-repo model)) (source (git-diff-model-source model))) (if (and line path (not (string=? (git-diff-model-kind model) "commit"))) ((op open-source:) state #{ kind: file: root: (dict-ref source root: #f) provider: (dict-ref source provider: #f) host: (dict-ref source host: #f) address: (string-append (dict-ref repo worktree: "") "/" path) line: (or (dict-ref line new-line: #f) (dict-ref line old-line: 1)) }) state))) ((not row) state) ((eq? (dict-ref row kind: #f) commit:) (open-diff state)) ((eq? (dict-ref row kind: #f) section:) (toggle state)) ((eq? (dict-ref row kind: #f) entry:) (let* ((entry (dict-ref row entry: #{}) ) (path (dict-ref entry path: #f)) (repo (dict-ref (dict-ref (git-status-model-state (git-status-model v)) data: #{}) repo: #{}))) (if path ((op open-source:) state #{ kind: file: root: (dict-ref (view-source v) root: #f) provider: (dict-ref (view-source v) provider: #f) host: (dict-ref (view-source v) host: #f) address: (string-append (dict-ref repo worktree: "") "/" path) }) state))) ((dict-ref row owner: #f) (let* ((entry-row (row-by-id v (dict-ref row owner: ""))) (entry (and entry-row (dict-ref entry-row entry: #{}))) (path (and entry (dict-ref entry path: #f))) (repo (dict-ref (dict-ref (git-status-model-state (git-status-model v)) data: #{}) repo: #{}))) (if path ((op open-source:) state #{ kind: file: root: (dict-ref (view-source v) root: #f) provider: (dict-ref (view-source v) provider: #f) host: (dict-ref (view-source v) host: #f) address: (string-append (dict-ref repo worktree: "") "/" path) line: (or (dict-ref row new-line: #f) (dict-ref row old-line: 1)) }) state))) (else state)))) (define (select-visit state suffix) (visit (select state suffix))) (define (select-diff state suffix) (open-diff (select state suffix))) (define (as-list value) (cond ((array? value) (array->list value)) ((list? value) value) (else '()))) (define (entry-path entry) (dict-ref entry path: (dict-ref entry original-path: #f))) (define (mutation-rows v stage? allow-section?) (let* ((selected (git-status-selected-row v)) (kind (and selected (dict-ref selected kind: #f))) (wanted (if stage? (list unstaged: untracked:) (list staged:)))) (cond ((and (eq? kind entry:) (member (dict-ref selected section: #f) wanted)) (list selected)) ((and allow-section? (eq? kind section:) (member (dict-ref selected section: #f) wanted)) (filter (lambda (row) (and (eq? (dict-ref row kind: #f) entry:) (eq? (dict-ref row section: #f) (dict-ref selected section: #f)))) (git-status-rows v))) (else '())))) (define (fresh-entry data path) (find (lambda (entry) (let ((current (dict-ref entry path: #f)) (original (dict-ref entry original-path: #f))) (or (and (string? current) (string=? current path)) (and (string? original) (string=? original path))))) (as-list (dict-ref data entries: '())))) (define (run-mutation-queue! model source verb paths) (if (null? paths) #t (let* ((data (dict-ref (git-status-model-state model) data: #{})) (entry (fresh-entry data (car paths)))) (if (not entry) (begin (git-status-model-refresh! model source (current-project ((op state:)))) #f) (git-status-model-mutate! model source verb (dict-ref entry id: "") (lambda (fresh) (run-mutation-queue! model source verb (cdr paths))) (lambda (error) ;; Stale and all other named failures converge on an ;; immediate authoritative refresh; no remaining path runs. (git-status-model-refresh! model source (current-project ((op state:)))))))))) (define (mutate-selected stage? allow-section?) (lambda (state) (let ((v (active-view state))) (if (not (status-view? v)) state (let* ((selected (git-status-selected-row v))Showing the first 500 of 630 diff lines for this file. This diff is INCOMPLETE; read the file or clone the repository for the rest.
packages/slate/src/slate/commands/git/branch.sgladded
;;; Branch prompts and commands; Git stays on the source's owning node.(define-library (slate commands git branch) (import (sigil io) (sigil json) (sigil string) (slate command) (slate prompt) (slate provider) (slate state) (slate view) (slate git model) (slate git status-view) (slate git log-model) (slate git log-view) (slate git diff-model) (slate git diff-view)) (export configure-git-branch-commands! git-branch-prompt) (begin (define *ops* #{}) (define *pending* '()) (define menu #{ "b" "git-branch-switch" "c" "git-branch-create-switch" "n" "git-branch-create-selected" }) (define (configure-git-branch-commands! ops) (set! *ops* ops)) (define (op key) (dict-ref *ops* key #f)) (define (as-list value) (cond ((array? value) (array->list value)) ((list? value) value) (else '()))) (define (model v) (and v (or (git-status-model v) (git-log-model v) (git-diff-model v)))) (define (repository v) (cond ((not v) #f) ((git-status-model v) (dict-ref (dict-ref (git-status-model-state (git-status-model v)) data: #{}) repo: #f)) ((git-log-model v) (git-log-model-repo (git-log-model v))) ((git-diff-model v) (git-diff-model-repo (git-diff-model v))) (else #f))) (define (selected-oid v) (let ((commit (cond ((git-log-model v) (if (eq? (dict-ref v slate/git-log-focus: 'list) 'review) (dict-ref (or (dict-ref (git-log-model-state (git-log-model v)) review: #f) #{}) commit: #f) (git-log-selected v))) ((git-status-model v) (dict-ref (or (git-status-selected-row v) #{}) commit: #f)) (else (dict-ref v slate/git-commit: #f))))) (and commit (dict-ref commit oid: #f)))) (define (context-owner? state ctx) (let ((v (find-view (views state) (vector-ref ctx 2)))) (and (eq? (dict-ref state slate/git-branch-context: #f) ctx) v (eq? (model v) (vector-ref ctx 3))))) (define (context-live? state ctx) (and (context-owner? state ctx) (equal? (dict-ref state slate/minibuffer: #f) "git-branch"))) (define (close-branch-prompt state) (dict-set (close-prompt state) slate/git-branch-context: #f)) (define (cancellable prompt) (dict-set prompt on-cancel: (lambda (state) (dict-set state slate/git-branch-context: #f)))) (define (request state source verb args ready fail) ;; A provider may answer synchronously in tests or from a cache. Preserve ;; the reducer return in that case; asynchronous replies use live state. (let ((sync? #t) (next state)) (define (deliver handler value) (if sync? (set! next (handler next value)) (begin ((op set-state:) (handler ((op state:)) value)) ((op refresh:))))) (source-git-request source verb args (lambda (reply) (deliver ready reply)) (lambda (error) (deliver fail error))) (set! sync? #f) next)) (define (message error) (if (dict? error) (dict-ref error message: "Branch operation failed; refresh before retrying") "Branch operation failed; refresh before retrying")) (define (valid-list? reply repo) (and (dict? reply) (= (dict-ref reply version: 0) 1) (git-repository=? repo (dict-ref reply repo: #f)) (git-snapshot-key (dict-ref reply snapshot: #f)) (dict? (dict-ref reply branch: #f)) (string? (dict-ref (dict-ref reply branch: #{}) head: #f)) (or (list? (dict-ref reply branches: #f)) (array? (dict-ref reply branches: #f))) (<= (bytevector-length (string->utf8 (json-encode reply))) 262144) (<= (length (as-list (dict-ref reply branches: '()))) 500) (let loop ((rows (as-list (dict-ref reply branches: '())))) (or (null? rows) (and (dict? (car rows)) (string? (dict-ref (car rows) name: #f)) (git-history-oid? (dict-ref (car rows) oid: #f)) (loop (cdr rows))))))) (define (related-status-models state repo) (map git-status-model (filter (lambda (v) (and (git-status-model v) (git-repository=? repo (repository v)))) (views state)))) (define (busy? state repo) (or (member (git-repository-key repo) *pending*) (any (lambda (m) (let ((s (git-status-model-state m))) (or (eq? (dict-ref s state: #f) loading:) (and (eq? (dict-ref s state: #f) stale:) (not (dict-ref s external-change?: #f))) (eq? (dict-ref s mutation: #f) pending:)))) (related-status-models state repo)))) (define (refresh-reviews state repo switched?) (fold-left (lambda (next v) (if (not (git-repository=? repo (repository v))) next (cond ((git-log-model v) (if switched? (begin (git-log-model-load! (git-log-model v)) (update-view next (view-name v) (lambda (current) (dict-set (dict-set current slate/git-selected: #f) slate/git-log-focus: 'list)))) (begin (git-log-model-refresh! (git-log-model v)) next))) ((not switched?) next) ((git-status-model v) (for-each (lambda (pair) (git-diff-model-invalidate! (cdr pair))) (dict-ref v slate/git-inline-models: '())) (update-view next (view-name v) (lambda (current) (dict-set (dict-set current slate/git-inline-models: '()) slate/git-hunk-folded: '())))) ((git-diff-model v) (git-diff-model-invalidate! (git-diff-model v)) next) (else next)))) state (views state))) (define (perform state ctx action name target) (let* ((repo (vector-ref ctx 1)) (data (vector-ref ctx 5)) (switch? (not (eq? action 'selected))) (key (git-repository-key repo))) (cond ((not (context-owner? state ctx)) (close-branch-prompt state)) ((busy? state repo) (with-echo (close-branch-prompt state) "Wait for the current Git operation")) (else (let* ((locks (map git-status-model-lock! (related-status-models state repo))) (args #{ repo: repo expected-snapshot: (dict-ref data snapshot: #{}) expected-branch: (dict-ref (dict-ref data branch: #{}) head: "") name: name }) (args (if (eq? action 'switch) (dict-set args target-oid: target) (dict-set (dict-set args start-oid: target) switch: switch?)))) (define (settle! fresh) (set! *pending* (filter (lambda (k) (not (equal? k key))) *pending*)) (for-each (lambda (lock) (git-status-model-unlock! lock fresh)) locks)) (set! *pending* (cons key *pending*)) (request (with-echo (close-branch-prompt state) (string-append "Git: updating branch " name "…")) (vector-ref ctx 0) (if (eq? action 'switch) "git/switch-branch" "git/create-branch") args (lambda (current reply) (if (and (dict? reply) (= (dict-ref reply version: 0) 1) (git-repository=? repo (dict-ref reply repo: #f)) (eq? (dict-ref reply applied: #f) #t) (equal? name (dict-ref reply name: #f))) (let ((fresh (dict-ref reply status: #f))) ;; Clear old inline reviews before status listeners see ;; fresh data, so a branch switch never eagerly reloads ;; patches belonging to the previous checkout. ((op set-state:) (refresh-reviews current repo switch?)) (settle! fresh) (with-echo ((op state:)) (string-append (if switch? "Switched to " "Created branch ") name (if fresh "" "; refresh status to update this repository")))) (begin (settle! #f) (with-echo current "Could not verify the branch result; refresh before retrying")))) (lambda (current error) (settle! #f) (with-echo current (message error))))))))) (define (loaded-prompt ctx action) (let* ((data (vector-ref ctx 5)) (head (dict-ref (dict-ref data snapshot: #{}) head-oid: "")) (current (dict-ref (dict-ref data branch: #{}) head: "")) (start (if (eq? action 'selected) (vector-ref ctx 4) head))) (if (eq? action 'switch) (completing-read-prompt (string-append "Switch local branch · " current) (lambda (state) (map (lambda (branch) #{ label: (dict-ref branch name: "") annotation: (if (equal? current (dict-ref branch name: #f)) "current" (substring (dict-ref branch oid: "") 0 7)) action: "noop" branch: branch }) (as-list (dict-ref data branches: '())))) (lambda (state candidate) (let ((branch (dict-ref candidate branch: #{}))) (perform state ctx action (dict-ref branch name: "") (dict-ref branch oid: #f))))) (string-prompt (if (eq? action 'selected) (string-append "Create branch at " (substring start 0 7) " · keep current checkout") (string-append "Create and switch from " current)) (lambda (state name) (cond ((or (string=? name "") (> (string-length name) 240)) "Enter a branch name (up to 240 characters)") ((find (lambda (b) (equal? name (dict-ref b name: #f))) (as-list (dict-ref data branches: '()))) "That branch already exists") (else #t))) (lambda (state name) (perform state ctx action name start)))))) (define (open-branch-prompt action) (lambda (state) (let* ((v (active-view state)) (repo (repository v)) (selected (and v (selected-oid v)))) (cond ((not repo) (with-echo state "Open Git status or history first")) ((busy? state repo) (with-echo state "Wait for the current Git operation")) ((and (eq? action 'selected) (not (git-history-oid? selected))) (with-echo state "Select a commit in history before creating a branch from it")) (else (let* ((ctx (vector (view-source v) repo (view-name v) (model v) selected #f)) ;; The loading placeholder must not become this prompt ;; session's candidate snapshot. Snapshot the loaded list. (loading (dict-set (completing-read-prompt "Loading branches…" (lambda (s) '()) (lambda (s c) s)) candidate-policy: live:)) (next (open-prompt (dict-set (dict-set state slate/git-branch-context: ctx) slate/git-branch-prompt: (cancellable loading)) "git-branch" (list "git-branch")))) (request next (view-source v) "git/branches" #{ repo: repo } (lambda (current reply) (if (not (context-live? current ctx)) current (if (valid-list? reply repo) (begin (vector-set! ctx 5 reply) (dict-set current slate/git-branch-prompt: (cancellable (loaded-prompt ctx action)))) (with-echo (close-branch-prompt current) "Invalid branch listing from this root")))) (lambda (current error) (if (context-live? current ctx) (with-echo (close-branch-prompt current) (message error)) current))))))))) (define (git-branch-prompt state) (dict-ref state slate/git-branch-prompt: (completing-read-prompt "Branches" (lambda (s) '()) (lambda (s c) s)))) (define (open-menu state) (if (not (repository (active-view state))) (with-echo state "Open Git status or history first") (dict-set (dict-set (with-echo state "b-") slate/pending-prefix: menu) slate/which-key: #{ title: "Branches" map: menu labels: #{ "b" "Switch local branch" "c" "Create and switch branch" "n" "Branch from selected commit" } }))) (install-builtin-command! "git-branch-menu" "Git: Branches" "switch or create a branch" '(slate commands git branch) open-menu) (install-builtin-command! "git-branch-switch" "Git: Switch branch" "choose a local branch" '(slate commands git branch) (open-branch-prompt 'switch)) (install-builtin-command! "git-branch-create-switch" "Git: Create and switch branch" "start a branch from HEAD" '(slate commands git branch) (open-branch-prompt 'create-switch)) (install-builtin-command! "git-branch-create-selected" "Git: Branch from selected commit" "create a branch without switching" '(slate commands git branch) (open-branch-prompt 'selected))))packages/slate/src/slate/commands/help.sglmodified
(close-prompt (with-echo state (describe-hook (dict-ref candidate hook: #f))))))) ((string=? id "describe-command") (completing-read-prompt "Describe command" describe-command-candidates (lambda (state candidate) (close-prompt (with-echo state (describe-command state (dict-ref candidate command-name: ""))))))) ;; Concrete family commands are synthesized from the current query. (dict-set (completing-read-prompt "Describe command" describe-command-candidates (lambda (state candidate) (close-prompt (with-echo state (describe-command state (dict-ref candidate command-name: "")))))) candidate-policy: live:)) (else #f))) (define (install-help-commands!)packages/slate/src/slate/commands/navigation.sglmodified
(slate modes terminal) (slate modes web) (slate modes welcome) (slate modes node-status) (slate view)) (export install-navigation-commands! configure-navigation-commands!) (begin (with-echo (close-prompt (ensure-view state welcome-view-name make-welcome-view)) "welcome")) (define (open-node-status-command state) (with-echo (close-prompt (ensure-view state node-status-view-name make-node-status-view)) "local node status")) (define (refresh-node-status-command state) (close-prompt (update-view state node-status-view-name (lambda (view) (if (node-status? view) (node-status-loading view) view))))) (define (update-client-command state) (call update-client: (close-prompt state))) (define (wake-lock-command state) (call wake-lock: (close-prompt state))) (define (performance-start-command state) ((cap performance-capture:) "start" (close-prompt state))) (define (performance-save-command state) ((cap performance-capture:) "save" (close-prompt state))) (define (close-view-command state) (let ((view (active-view state))) (cond "open the scratchpad view" source-module open-scratchpad-command) (install-builtin-command! "welcome" "View: Welcome" "open the welcome view" source-module open-welcome-command) (install-builtin-command! "node-status" "Slate: Node Status" "show this local node's lifecycle and listener status" source-module open-node-status-command) (install-builtin-command! "node-status-refresh" "Node: Refresh Status" "refresh this local node's lifecycle and listener status" source-module refresh-node-status-command) (install-builtin-command! "slate-update-client" "Slate: Update Client" "activate Slate's waiting web client update, then reload once" source-module update-client-command) (install-builtin-command! "slate-keep-screen-awake" "Slate: Keep Screen Awake" "toggle keeping the screen awake while Slate is visible" source-module wake-lock-command) (install-builtin-command! "slate-performance-start" "Slate: Start Performance Capture" "begin a bounded client input and paint timing capture" source-module performance-start-command) (install-builtin-command! "slate-performance-save" "Slate: Save Performance Capture" "stop the client timing capture and download its JSON report" source-module performance-save-command) (install-builtin-command! "close-view" "View: Close" "close the focused view" source-module close-view-command) (install-builtin-command! "clear" "View: Clear"packages/slate/src/slate/commands/project.sglmodified
(ff-cache-reset!) ((required drill-query:) (dict-set next slate/findfile-provider: context) "findfile" address))))) "findfile" (dir-path address)))))) (define (project-terminal-command state) (with-current-project statepackages/slate/src/slate/commands/search.sglmodified
(dict-set (dict-set (dict-set (dict-set (dict-set (dict-set (completing-read-prompt "Search in buffer" search-candidates search-goto) rank: search-rank) completes-query?: #f) empty-action: search-empty-action) preview: search-preview) on-cancel: search-cancel)) preview: search-preview) on-cancel: search-cancel) candidate-policy: live:)) (define (install-search-commands!) (install-builtin-command-family! "search-goto:"packages/slate/src/slate/commands/terminal.sglmodified
;;; (slate commands terminal) — terminal and tmux command surface.(define-library (slate commands terminal) (import (sigil core) (sigil array) (sigil math) (slate components) (sigil string) (slate command) (slate list) (slate main preferences) (slate modes terminal) (slate pane) (slate prompt) (slate prompt candidates) (slate provider) terminal-char-mode-active? terminal-key! terminal-send-binding? tmux-caps-clear-inflight! tmux-caps-fetch! tmux-caps-for tmux-client-of tmux-close! tmux-detach! tmux-reattach! next-term-name) next-term-name terminal-session-candidates) (begin (define source-module '(slate commands terminal)) (define *ops* #{}) (for-each (lambda (row) (exact (car row) (cadr row) (caddr row) (cadddr row))) (list (list "open-terminal" "Terminal: Open" "open a terminal" open-terminal-command) (list "terminal-reattach" "Terminal: Reattach" "choose a running terminal to reattach" terminal-reattach-command) (list "terminal-close" "Terminal: Close" "close the terminal" terminal-close-command) (list "terminal-live" "Terminal: Scroll to Live" "scroll to live output" terminal-live-command) (list "terminal-toggle-mode" "Terminal: Toggle Mode" "toggle char/line mode" terminal-toggle-command) (list "terminal-reconnect" "Terminal: Reconnect" "request a fresh terminal session" (lambda (s) (terminal-reconnect-command s #f))) (list "terminal-reconnect" "Terminal: New Session" "replace a disconnected terminal with a new session" (lambda (s) (terminal-reconnect-command s #f))) (list "tmux-next-window" "Tmux: Next Window" "select next tmux window" (lambda (s) (tmux-step-command s #t))) (list "tmux-prev-window" "Tmux: Previous Window" "select previous tmux window" (lambda (s) (tmux-step-command s #f))) (list "tmux-split-right" "Tmux: Split Right" "split tmux pane right" (lambda (s) (tmux-split-command s 'row))) (list "tmux-rename-window" "Tmux: Rename Window" "rename tmux window" (lambda (s) (tmux-rename-command s "tmux-rename-window"))) (list "tmux-rename-session" "Tmux: Rename Session" "rename tmux session" (lambda (s) (tmux-rename-command s "tmux-rename-session"))) (list "tmux-sessions" "Tmux: Switch Session" "choose tmux session" tmux-sessions-command))) (family "terminal-attach:" "reattach a selected terminal" terminal-attach-command) (family "tmux-select-window:" "select tmux window" tmux-select-window-command) (family "tmux-select-pane:" "select tmux pane" tmux-select-pane-command) (family "tmux-inner-resize:" "resize inner tmux pane" tmux-inner-resize-command) (family "session-switch:" "switch tmux session" tmux-session-switch-command) (family "terminal-reconnect-pane:" "reconnect a pane terminal" terminal-reconnect-command) (family "terminal-reconnect-pane:" "open a new session in a disconnected terminal pane" terminal-reconnect-command) (family "term-mode-toggle-pane:" "toggle a pane terminal mode" terminal-toggle-pane-command) #t) (define *terminal-picker-generation* 0) (define (terminal-session-candidates state) (let loop ((rows (dict-ref state slate/terminal-sessions: '())) (index 0)) (if (null? rows) '() (let* ((row (car rows)) (sid (dict-ref row sid: "")) (command (dict-ref row command: "shell")) (cwd (dict-ref row cwd: "")) (idle (dict-ref row idle: 0))) (cons (cand (string-append command " · " sid) (string-append (if (dict-ref row attached: #f) "attached" "detached") " · idle " (age-phrase (* 1000 idle)) (if (string=? cwd "") "" (string-append " · " cwd))) (string-append "terminal-attach:" (number->string index))) (loop (cdr rows) (+ index 1))))))) (define (terminal-reattach-command state) (set! *terminal-picker-generation* (+ *terminal-picker-generation* 1)) (let* ((generation *terminal-picker-generation*) (host (active-terminal-host state)) (request (if (string=? host "") node-list-sessions! (lambda (ok err) (enclave-node-list-sessions! host ok err)))) (next (drill-prompt (dict-set (dict-set state slate/terminal-sessions: '()) slate/terminal-picker-host: host) "terminal-session")) (prompt-session (dict-ref next slate/prompt-session: 0)) (issuing? #t)) (define (current?) (and (= generation *terminal-picker-generation*) (= prompt-session (dict-ref (root-state) slate/prompt-session: 0)) (equal? (dict-ref (root-state) slate/minibuffer: #f) "terminal-session"))) (define (update! change) ;; A disconnected provider may answer before dispatch installs next. (if issuing? (set! next (change next)) (when (current?) (set-root-state! (change (root-state))) (slate-refresh)))) (set! next (with-echo next "Listing running terminals…")) (request (lambda (result) (let* ((raw (dict-ref result sessions: '())) (rows (filter (lambda (row) (and (dict? row) (equal? (dict-ref row kind: #f) "pty") (dict-ref row alive: #f) (string? (dict-ref row sid: #f)))) (cond ((array? raw) (array->list raw)) ((list? raw) raw) (else '())))) (message (cond ((not (dict-ref result catalog-version: #f)) "Update this node to discover all retained terminals") ((> (dict-ref result unavailable: 0) 0) "Some terminals could not be listed; their node processes may need updating") ((null? rows) "No running terminals on this node") (else "Select a terminal to reattach")))) (update! (lambda (state) (with-echo (dict-set state slate/terminal-sessions: rows) message))))) (lambda (reason) (update! (lambda (state) (with-echo state (string-append "Cannot list terminals: " (error->text reason))))))) (set! issuing? #f) next)) (define (terminal-attach-command state arg) (let* ((index (and (string? arg) (string->number arg))) (rows (dict-ref state slate/terminal-sessions: '())) (host (dict-ref state slate/terminal-picker-host: ""))) (if (not (and (integer? index) (>= index 0) (< index (length rows)))) (with-echo state "That terminal is no longer in the list") (let* ((row (list-ref rows index)) (sid (dict-ref row sid: "")) (existing (let loop ((vs (views state))) (and (pair? vs) (let* ((view (car vs)) (source (view-source view))) (if (and (terminal? view) (equal? host (dict-ref source host: "")) (equal? sid (terminal-remote-id (view-name view)))) (view-name view) (loop (cdr vs)))))))) (if existing (focus-view (close-prompt state) existing) (let* ((name (next-term-name)) (source #{ kind: pty: provider: (if (string=? host "") local: node:) host: host attach: sid attach-route: (dict-ref row route: #f) }) (view (make-terminal-view name source))) (with-echo (focus-view (add-view (close-prompt state) view) name) "Reattaching terminal…"))))))) (define (open-terminal-command state) (cond ;; ---- a discovered Enclave machine root -------------------------packages/slate/src/slate/components/diff.sgladded
;;; (slate components diff) — reusable normalized diff presentation.(define-library (slate components diff) (import (sigil core) (sigil string) (sigil web client)) (export diff-normalize-unified diff-kind=? diff-row-kind diff-row-class diff-row-render diff-control-render) (begin (define (number-text value) (if (number? value) (number->string value) "")) (define (kind-text value) (cond ((keyword? value) (keyword->string value)) ((symbol? value) (symbol->string value)) ((string? value) value) (else "context"))) (define (diff-kind=? value expected) (or (eq? value expected) (string=? (kind-text value) (keyword->string expected)))) ;; Some older node decoders preserve keyword-shaped JSON values as opaque ;; atoms. Patch text is not mutation authority, but its first column is a ;; safe presentation fallback for classifying those already-structured rows. (define (diff-row-kind row) (let ((kind (dict-ref row kind: #f)) (text (dict-ref row text: ""))) (cond ((memq kind '(addition: deletion: hunk: file: loading: continuation: context:)) kind) ((diff-kind=? kind addition:) addition:) ((diff-kind=? kind deletion:) deletion:) ((diff-kind=? kind hunk:) hunk:) ((diff-kind=? kind file:) file:) ((diff-kind=? kind loading:) loading:) ((diff-kind=? kind continuation:) continuation:) ((and (string? text) (string-starts-with? text "+")) addition:) ((and (string? text) (string-starts-with? text "-")) deletion:) ((and (string? text) (string-starts-with? text "@@")) hunk:) ((and (string? text) (string-starts-with? text "diff --git ")) file:) (else context:)))) (define (diff-row-class row) (kind-class (diff-row-kind row))) (define (kind-class kind) (cond ((eq? kind addition:) "slate-diff-row slate-diff-addition") ((eq? kind deletion:) "slate-diff-row slate-diff-deletion") ((eq? kind hunk:) "slate-diff-row slate-diff-hunk") ((eq? kind file:) "slate-diff-row slate-diff-file") ((eq? kind loading:) "slate-diff-row slate-diff-loading") ((eq? kind continuation:) "slate-diff-row slate-diff-continuation") (else "slate-diff-row slate-diff-context"))) (define (diff-row-render row selected? command) (let* ((kind (diff-row-kind row)) (class (string-append (kind-class kind) (if selected? " selected" ""))) (action? (or (eq? kind hunk:) (eq? kind continuation:))) (text (dict-ref row text: "")) (display-text (if (and (or (eq? kind addition:) (eq? kind deletion:)) (> (string-length text) 0)) (substring text 1 (string-length text)) text))) `(div (@ (class ,class) (role "row") (data-diff-kind ,(kind-text kind)) ,@(if command `((data-slate-click ,command)) '())) (span (@ (class "slate-diff-gutter slate-diff-old") (aria-hidden "true")) ,(if (and command (eq? kind hunk:)) `(button (@ (class "slate-diff-hunk-toggle") (aria-label "Toggle hunk") (aria-expanded ,(if (dict-ref row folded?: #f) "false" "true")) (data-slate-click ,command)) ,(if (dict-ref row folded?: #f) "▸" "▾")) (number-text (dict-ref row old-line: #f)))) (span (@ (class "slate-diff-gutter slate-diff-new") (aria-hidden "true")) ,(number-text (dict-ref row new-line: #f))) (span (@ (class "slate-diff-marker") (aria-hidden "true")) ,(cond ((eq? kind addition:) "+") ((eq? kind deletion:) "−") ((eq? kind hunk:) "@@") (else " "))) (span (@ (class "slate-diff-code")) ,display-text) ,@(if (and command action? (not (eq? kind hunk:))) `((button (@ (class "slate-diff-action") (aria-label "Toggle diff row") (data-slate-click ,command)) "▾")) '()) ,@(let ((mutation (dict-ref row mutation-command: #f))) (if mutation `((button (@ (class "slate-diff-mutation") (aria-label ,(dict-ref row mutation-label: "Apply hunk mutation")) (title ,(dict-ref row mutation-label: "Apply hunk mutation")) (data-slate-click ,mutation)) (span (@ (class ,(string-append "icon i-" (dict-ref row mutation-icon: "plus"))))))) '())) ,@(let ((discard (dict-ref row discard-command: #f))) (if discard `((button (@ (class "slate-diff-discard") (aria-label "Discard hunk permanently") (title "Discard hunk") (data-slate-click ,discard)) (span (@ (class "icon i-trash-2"))))) '()))))) (define (diff-control-render rows selected command-for (keys: (revision #f) (prepare-row #f))) ;; Bound sibling fanout: the DOM reconciler scans sibling records when ;; matching and removing children. Row groups preserve document order, ;; native scrolling, and the full bounded review without a flat hot list. ;; A supplied revision covers all row content, commands and attributes. ;; Only a group's own selection enters its memo revision: moving point ;; skips constructing and reconciling every unaffected group. (define (render-group rows) `(div (@ (role "rowgroup")) ,@(map (lambda (raw) (let* ((row (if prepare-row (prepare-row raw) raw)) (id (dict-ref row id: #f))) (diff-row-render row (and selected id (string=? selected id)) (and command-for (command-for row))))) rows))) (define (groups rest index) (if (null? rest) '() (let chunk ((tail rest) (n 0) (point #f) (out '())) (if (or (null? tail) (= n 32)) (let ((group (reverse out))) (cons (if revision (web-client-memo (string-append "diff-group:" (number->string index)) (format "~s" (list revision point)) (lambda () (render-group group))) (render-group group)) (groups tail (+ index 1)))) (let ((row (car tail))) (chunk (cdr tail) (+ n 1) (if (equal? selected (dict-ref row id: #f)) selected point) (cons row out))))))) `(div (@ (class "slate-diff-control") (role "table") (aria-label "Diff")) ,@(groups rows 0))) (define (range-start token) (let* ((body (if (> (string-length token) 1) (substring token 1 (string-length token)) "")) (parts (string-split body ","))) (and (not (null? parts)) (string->number (car parts))))) (define (hunk-starts line) (let ((parts (string-split line " "))) (if (>= (length parts) 3) (cons (range-start (list-ref parts 1)) (range-start (list-ref parts 2))) (cons #f #f)))) (define (line-kind line) (cond ((and (>= (string-length line) 2) (string=? (substring line 0 2) "@@")) hunk:) ((and (> (string-length line) 0) (char=? (string-ref line 0) #\+)) addition:) ((and (> (string-length line) 0) (char=? (string-ref line 0) #\-)) deletion:) ((and (>= (string-length line) 4) (string=? (substring line 0 4) "diff")) file:) (else context:))) (define (diff-normalize-unified text) (let loop ((lines (string-split text "\n")) (old #f) (new #f) (index 0) (out '())) (if (null? lines) (reverse out) (let* ((line (car lines)) (kind (line-kind line)) (starts (and (eq? kind hunk:) (hunk-starts line))) (old* (if starts (car starts) old)) (new* (if starts (cdr starts) new)) (old-line (if (or (eq? kind context:) (eq? kind deletion:)) old* #f)) (new-line (if (or (eq? kind context:) (eq? kind addition:)) new* #f)) (row #{ id: (string-append "raw-diff:" (number->string index)) kind: kind text: line old-line: old-line new-line: new-line })) (loop (cdr lines) (if old-line (+ old* 1) old*) (if new-line (+ new* 1) new*) (+ index 1) (cons row out))))))))packages/slate/src/slate/enclave-account/session.sglmodified
purpose: (dict-ref record purpose: "device") }) (define (account-on-ready!) (account-session-on-message! ((required node-message:))) (account-session-on-message! (required node-message:)) ((required node-persist-error:) (lambda (path error) ((required set-state:)packages/slate/src/slate/findfile.sglmodified
;; Completing to nothing is the right answer for a path that isn't readable; the ;; caller's empty-action then offers to create it. (define (make-listing-cache on-change) (let ((cache #{})) (let ((cache #{}) (row-cache #{})) #{ entries: (lambda (key start-read!) (let ((hit (dict-ref cache key #f))) 'loading)))) loading?: (lambda (key) (eq? (dict-ref cache key #f) 'loading)) ;; Candidate dictionaries are a pure projection of one directory's ;; entries. Cache that projection too: the listing itself was already ;; stable for this prompt invocation, so rebuilding hundreds of rows ;; for every leaf-character edit only burns the UI thread. rows: (lambda (key build) (let ((hit (dict-ref row-cache key #f))) (or hit (let ((rows (build))) (set! row-cache (dict-set row-cache key rows)) rows)))) ;; Reset when the prompt OPENS: fresh listings per invocation, cached ;; within one. So typing never re-reads, but a new C-x C-f sees new files. reset!: (lambda () (set! cache #{})) })) (lambda () (set! cache #{}) (set! row-cache #{})) })) ;; The candidate shape (slate selector) ranks and renders. Same three fields ;; (slate main)'s `cand` builds — the shape is already a contract between main ;; instead (common-prefix eats the trailing "/"). ⏎ on a file opens it in an ;; editor view. (define (entry-rows dir entries dirs-only?) (fold-left (lambda (acc e) (let* ((name (dict-ref e name: "")) (dir? (eq? (dict-ref e kind: file:) dir:)) (path (fs-join dir name))) (cond ((findfile-marker-entry? e) acc) (dir? (append acc (list (cand (string-append path "/") "" (string-append "openfolder:" path))))) (dirs-only? acc) (else (append acc (list (cand path "" (string-append "openpath:" path)))))))) '() entries)) (reverse (fold-left (lambda (acc e) (let* ((name (dict-ref e name: "")) (dir? (eq? (dict-ref e kind: file:) dir:)) (path (fs-join dir name))) (cond ((findfile-marker-entry? e) acc) (dir? (cons (cand (string-append path "/") "" (string-append "openfolder:" path)) acc)) (dirs-only? acc) (else (cons (cand path "" (string-append "openpath:" path)) acc))))) '() entries))) ;; The full candidate list for a query over an already-read listing. (define (findfile-rows query entries dirs-only?) ((eq? kind file:) (dir-path (fs-parent addr))) (else "/")))) ;; The provider a prompt should complete against, given the view's source: that ;; source's own provider IS "the context" in the Emacs sense, so find-file from a ;; local: buffer completes local: and from a browser: buffer completes browser:. ;; Falls back when the source names nothing usable. Which provider is the DEFAULT ;; is the caller's business — this never names one. ;; The filesystem context a prompt should complete against, given the view's ;; source. Prefer its registered ROOT: a multi-machine provider such as node: ;; is not enough to route a read, while the matching root retains host:. The ;; built-in single-tree roots share their provider's name, so this preserves ;; their spelling. Fall back to the provider only when no root matches. (define (source-fs-provider src fallback) (let ((prov (and src (dict-ref src provider: #f)))) (if (and prov (provider-registered? prov)) prov fallback))) (let ((root (and src (source-root src))) (prov (and src (dict-ref src provider: #f)))) (cond (root root) ((and prov (provider-registered? prov)) prov) (else fallback)))) ;; ---- rooted queries ------------------------------------------------------ ;; Wired in (slate main): state-with-prompt-query (typing + Tab) applies the (cand (rooted-path n "/") (root-annotation n) (string-append "openroot:" (root-name-string n)))) (filter nav-root? (root-names)))) (filter nav-root? (root-visible-names)))) ;; ---- reading a directory through the seam -------------------------------- ;; A SYNCHRONOUS provider (browser:) materializes INLINE, exactly as find-file (entries (entries-for cache prov dir))) (if (eq? entries 'loading) (list (cand (string-append dir " — reading…") "" "noop")) (findfile-rows query entries dirs-only?))))))))) (append (self-row query) ((dict-ref cache rows:) (string-append (listing-key prov dir) (if dirs-only? "|dirs" "|all")) (lambda () (entry-rows dir entries dirs-only?))))))))))))packages/slate/src/slate/folder/enrichment.sgladded
;;; (slate folder enrichment) — optional reads after the listing is ready.;;; The view owns a cache; callbacks can only fill that cache while it is alive.;;; Neither enrichment replaces directory rows nor changes point/marks.(define-library (slate folder enrichment) (import (sigil string) (slate folder model) (slate git model) (slate modes folder) (slate modes image) (slate pane) (slate space) (slate provider) (slate state) (slate view)) (export refresh-folder-enrichment! folder-preview-request-token flush-folder-preview!) (begin (define *tracked* '()) (define *refreshing?* #f) (define (dispose! v) (clear-image! v) (folder-detail-set! v preview-start: #f) (folder-detail-set! v alive?: #f) (folder-detail-set! v preview-key: #f) (let ((model (folder-detail v git-model: #f))) (when model (git-status-model-dispose! model)))) (define (directory-excerpt entries) (let ((entries (if (vector? entries) (vector->list entries) entries))) (let loop ((rest entries) (n 0) (text "")) (cond ((null? rest) (if (= n 0) "Empty directory" text)) ((>= n 20) (string-append text "…")) (else (loop (cdr rest) (+ n 1) (string-append text (dict-ref (car rest) name: "") (if (eq? (dict-ref (car rest) kind: file:) dir:) "/" "") "\n"))))))) ;; A trailing browser timer starts the latest request after 500 ms at point. ;; One request across folder views can be in flight, so even a slow or stuck ;; node cannot accumulate a queue of obsolete file reads during navigation. (define *preview-serial* 0) (define *preview-busy?* #f) (define (release-image! source url) (when (and source url) (let ((release (dict-ref (provider-for source) image-release: #f))) (when release (guard (error (else #f)) (release source url)))))) (define (clear-image! v) (release-image! (folder-detail v preview-image-source: #f) (folder-detail v preview-image-url: #f)) (folder-detail-set! v preview-image-url: #f) (folder-detail-set! v preview-image-source: #f)) (define (folder-preview-request-token) (if (and (not *preview-busy?*) (any (lambda (v) (folder-detail v preview-start: #f)) *tracked*)) (number->string *preview-serial*) "")) (define (flush-folder-preview! token) (when (and (not (string=? token "")) (string=? token (folder-preview-request-token))) (let loop ((remaining *tracked*)) (unless (null? remaining) (let ((start (folder-detail (car remaining) preview-start: #f))) (if start (begin (folder-detail-set! (car remaining) preview-start: #f) (set! *preview-busy?* #t) (start)) (loop (cdr remaining)))))))) (define (preview-start v entry source key generation notify) (lambda () (let* ((returned? #f) (completed? #f) (current? (lambda () (and (folder-detail v alive?: #f) (= generation (folder-detail v preview-generation: 0)) (equal? key (folder-detail v preview-key: #f))))) (finish (lambda (text) (unless completed? (set! completed? #t) (set! *preview-busy?* #f) (when (current?) (folder-detail-set! v preview-text: text)) ;; Even an obsolete response releases the request slot ;; and lets the browser arm the latest pending preview. (when (and returned? (not *refreshing?*)) (notify))))) (failed (lambda (error) (finish "Preview unavailable"))) (ready (lambda (value) (finish (if (eq? (dict-ref source kind: file:) dir:) (directory-excerpt value) (folder-preview-excerpt value))))) (read! (lambda () (guard (error (else (failed error))) (let ((async (provider-async-op source))) (if async (async source ready failed) (ready (source-materialize source))))))) (check-size (lambda (stat) (let ((size (and stat (dict-ref stat size: #f)))) (cond ((not (current?)) (finish "")) ((not (number? size)) (finish "Size unavailable · open to inspect")) ((> size 262144) (finish "Large file · open to inspect")) (else (read!))))))) (folder-detail-set! v preview-text: "Loading…") (guard (error (else (failed error))) (cond ((not (current?)) (finish "")) ((eq? (dict-ref source kind: file:) dir:) (read!)) ((image-extension? (dict-ref entry name: "")) (let ((image-op (dict-ref (provider-for source) image-url: #f))) (if (not image-op) (finish "Image preview unavailable") (image-op source (lambda (url) (if (and (not completed?) (current?)) (begin (folder-detail-set! v preview-image-source: source) (folder-detail-set! v preview-image-url: url)) (release-image! source url)) (finish "")) failed)))) ((number? (dict-ref entry size: #f)) (check-size entry)) (else (source-stat source check-size failed)))) (set! returned? #t)))) (define (preview! v visible? notify) (let* ((entry (folder-entry-at v)) (enabled? (and visible? (dict-ref v slate/folder-preview?: #f))) (source (and entry enabled? (folder-child-source v entry))) (key (and source (list source (dict-ref v slate/folder-revision: 0))))) (unless (equal? key (folder-detail v preview-key: #f)) (clear-image! v) (set! *preview-serial* (+ *preview-serial* 1)) (folder-detail-set! v preview-key: key) (folder-detail-set! v preview-generation: (+ 1 (folder-detail v preview-generation: 0))) (folder-detail-set! v preview-text: "Waiting…") (folder-detail-set! v preview-start: (and source (preview-start v entry source key (folder-detail v preview-generation: 0) notify)))))) (define (git! v notify) (let* ((source (view-source v)) (provider (provider-for source)) (revision (dict-ref v slate/folder-revision: 0))) (when (and (dict-ref provider git-request: #f) (not (equal? revision (folder-detail v git-revision: #f)))) (folder-detail-set! v git-revision: revision) (folder-detail-set! v git-capable?: #t) (let* ((returned? #f) (model (or (folder-detail v git-model: #f) (make-git-status-model (lambda (state) (when (folder-detail v alive?: #f) (let* ((error (dict-ref state error: #f)) (code (and (dict? error) (dict-ref error code: #f)))) (when (or (equal? code "not-a-repository") (eq? code not-a-repository:) (eq? (dict-ref state state: #f) unavailable:)) (folder-detail-set! v git-capable?: #f) (folder-detail-set! v git-overlay: #{}))) (folder-detail-set! v git-state: state) (let ((data (dict-ref state data: #f))) (when (and data (not (eq? data (folder-detail v git-data: #f)))) (folder-detail-set! v git-data: data) (folder-detail-set! v git-overlay: (folder-git-overlay (folder-address v) data)))) (when (and returned? (not *refreshing?*)) (notify)))))))) (folder-detail-set! v git-model: model) (git-status-model-load! model source #f) (set! returned? #t))))) (define (refresh-folder-enrichment! state now notify) (let* ((folders (filter folder? (views state))) (space (active-space state)) (visible (if space (map leaf-view (space-leaves space)) (list)))) (set! *refreshing?* #t) (for-each (lambda (old) (unless (any (lambda (v) (eq? (folder-cache old) (folder-cache v))) folders) (dispose! old))) *tracked*) (set! *tracked* folders) (for-each (lambda (v) (folder-detail-set! v alive?: #t) (folder-detail-set! v now: now) (when (folder-detail v listing-ready?: #f) (guard (error (else (folder-detail-set! v git-state: #{ state: error: }))) (git! v notify)) (preview! v (member (view-name v) visible) notify))) folders) (set! *refreshing?* #f)))))packages/slate/src/slate/folder/model.sgladded
;;; (slate folder model) — directory projection and optional metadata.(define-library (slate folder model) (import (sigil list) (sigil math) (sigil string) (slate providers localstorage)) (export folder-project-entries folder-entry-hidden? folder-modified-text folder-git-overlay folder-preview-excerpt) (begin (define (folder-entry-hidden? entry) (dict-ref entry hidden: (string-starts-with? (dict-ref entry name: "") "."))) (define (folder-project-entries entries sort hidden?) (list->vector (list-sort (lambda (a b) (let* ((ad (eq? (dict-ref a kind: file:) dir:)) (bd (eq? (dict-ref b kind: file:) dir:)) (field (if (eq? sort size:) size: mtime:)) (av (dict-ref a field #f)) (bv (dict-ref b field #f))) (cond ((not (eq? ad bd)) ad) ((and (not (eq? sort name:)) (not (eq? (number? av) (number? bv)))) (number? av)) ((and (not (eq? sort name:)) (number? av) (number? bv) (not (= av bv))) (if (eq? sort oldest:) (< av bv) (> av bv))) (else (string<? (dict-ref a name: "") (dict-ref b name: "")))))) (filter (lambda (entry) (or hidden? (not (folder-entry-hidden? entry)))) (vector->list entries))))) (define (folder-modified-text entry now) (let ((mtime (dict-ref entry mtime: #f))) (if (not (and (number? mtime) (number? now))) "—" (let ((age (max 0 (- now mtime)))) (cond ((< age 60) "just now") ((< age 3600) (string-append (number->string (floor (/ age 60))) "m ago")) ((< age 86400) (string-append (number->string (floor (/ age 3600))) "h ago")) (else (string-append (number->string (floor (/ age 86400))) "d ago"))))))) (define (git-badge entry) (let* ((kind (dict-ref entry kind: "ordinary")) (index (dict-ref entry index-state: ".")) (worktree (dict-ref entry worktree-state: ".")) (conflict? (or (string=? kind "conflict") (string=? index "U") (string=? worktree "U"))) (untracked? (string=? kind "untracked")) (label (cond (conflict? "!") (untracked? "?") ((not (string=? worktree ".")) worktree) (else index)))) #{ label: label class: (cond (conflict? "conflict") (untracked? "untracked") ((string=? label "A") "added") (else "modified")) priority: (cond (conflict? 4) (untracked? 1) (else 2)) detail: (cond (conflict? "Conflict") (untracked? "Untracked") (else (string-append "Index: " index " · Worktree: " worktree))) })) ;; Build one name-keyed overlay per Git response. Directory aggregates are ;; derived from descendants, without inserting deleted paths into the listing. (define (folder-git-overlay address data) (let* ((repo (dict-ref data repo: #{})) (root (dict-ref repo worktree: "/")) (prefix (string-append (if (string=? address "/") "" address) "/")) (raw (dict-ref data entries: '())) (entries (if (array? raw) (array->list raw) raw))) (fold-left (lambda (out entry) (let ((full (fs-join root (dict-ref entry path: "")))) (if (not (string-starts-with? full prefix)) out (let* ((relative (substring full (string-length prefix) (string-length full))) (parts (string-split relative "/")) (name (car parts)) (badge (git-badge entry)) (badge (if (null? (cdr parts)) badge (dict-set badge detail: (string-append "Contains changes · " (dict-ref badge detail: ""))))) (old (dict-ref out name #f))) (if (and old (> (dict-ref old priority: 0) (dict-ref badge priority: 0))) out (dict-set out name badge)))))) #{} entries))) ;; Bound rendered text even for providers returning unusually long lines. ;; NUL-bearing data is treated as binary, never interpreted as HTML. (define (folder-preview-excerpt text) (if (not (string? text)) "Preview unavailable" (let ((limit (min 12000 (string-length text)))) (let loop ((i 0) (lines 0)) (cond ((or (>= i limit) (>= lines 80)) (string-append (substring text 0 i) (if (< i (string-length text)) "\n…" ""))) ((= (char->integer (string-ref text i)) 0) "Binary file") (else (loop (+ i 1) (if (char=? (string-ref text i) #\newline) (+ lines 1) lines))))))))))packages/slate/src/slate/git/diff-model.sgladded
;;; (slate git diff-model) — provider-neutral, generation-safe diff paging.(define-library (slate git diff-model) (import (sigil core) (sigil io) (sigil json) (slate provider) (slate git model)) (export make-git-diff-model git-diff-model? git-diff-model-state git-diff-model-generation git-diff-model-load! git-diff-model-more! git-diff-model-invalidate! git-diff-model-reload! git-diff-reduce git-diff-lines git-diff-model-defer! git-diff-model-resume! git-diff-model-source git-diff-model-repo git-diff-model-snapshot git-diff-model-kind git-diff-model-target) (begin ;; [tag state generation source repo snapshot kind target listener] (define (make-git-diff-model source repo snapshot kind target on-change) (vector 'git-diff-model #{ state: idle: pages: '() next-cursor: #f complete: #f } 0 source repo snapshot kind target on-change)) (define (git-diff-model? value) (and (vector? value) (= (vector-length value) 9) (eq? (vector-ref value 0) 'git-diff-model))) (define (git-diff-model-state model) (vector-ref model 1)) (define (git-diff-model-generation model) (vector-ref model 2)) (define (git-diff-model-source model) (vector-ref model 3)) (define (git-diff-model-repo model) (vector-ref model 4)) (define (git-diff-model-snapshot model) (vector-ref model 5)) (define (git-diff-model-kind model) (vector-ref model 6)) (define (git-diff-model-target model) (vector-ref model 7)) (define (emit! model state) (vector-set! model 1 state) (let ((listener (vector-ref model 8))) (when listener (listener state))) state) (define (as-list value) (cond ((array? value) (array->list value)) ((list? value) value) (else '()))) (define (git-diff-lines state) (fold-left (lambda (out page) (fold-left (lambda (lines file) (append lines (as-list (dict-ref file hunks: '())))) out (as-list (dict-ref page files: '())))) '() (as-list (dict-ref state pages: '())))) (define (git-diff-reduce state event) (case (dict-ref event event: error:) ((begin:) (dict-set (dict-set state state: (if (null? (dict-ref state pages: '())) loading: stale:)) generation: (dict-ref event generation: 0))) ((page:) (let* ((page (dict-ref event page: #{})) (all-pages (append (if (dict-ref state rebasing: #f) '() (dict-ref state pages: '())) (list page))) ;; Two v1 pages are the complete client-side render window. ;; Older pages are discarded instead of making state grow with ;; a multi-megabyte patch. (pages (if (> (length all-pages) 2) (list (list-ref all-pages (- (length all-pages) 2)) (list-ref all-pages (- (length all-pages) 1))) all-pages))) #{ state: ready: generation: (dict-ref event generation: 0) pages: pages next-cursor: (dict-ref page next-cursor: #f) complete: (dict-ref page complete: #f) })) (else (dict-set (dict-set state state: error:) error: (dict-ref event error: operation-failed:))))) (define (valid-page? model page) (and (dict? page) (= (dict-ref page version: 0) 1) (git-repository=? (dict-ref page repo: #{}) (vector-ref model 4)) (git-snapshot=? (dict-ref page snapshot: #{}) (vector-ref model 5)) (<= (dict-ref page payload-bytes: 262145) 262144) (<= (bytevector-length (string->utf8 (json-encode page))) 262144) (<= (length (fold-left (lambda (out file) (append out (as-list (dict-ref file hunks: '())))) '() (as-list (dict-ref page files: '())))) 400))) (define (cancel-current! model) (let ((cursor (dict-ref (git-diff-model-state model) next-cursor: #f))) (when cursor (source-git-request (vector-ref model 3) "git/diff" #{ repo: (vector-ref model 4) snapshot: (vector-ref model 5) kind: (vector-ref model 6) target: (vector-ref model 7) cursor: cursor cancel: #t max-lines: 400 max-bytes: 262144 } (lambda (ignored) #t) (lambda (ignored) #t))))) (define (cancel-page! model page repo snapshot) (let ((cursor (and (dict? page) (dict-ref page next-cursor: #f)))) (when cursor (source-git-request (vector-ref model 3) "git/diff" #{ repo: repo snapshot: snapshot kind: (vector-ref model 6) target: (vector-ref model 7) cursor: cursor cancel: #t max-lines: 400 max-bytes: 262144 } (lambda (ignored) #t) (lambda (ignored) #t))))) (define (request! model cursor reset?) (let* ((generation (+ 1 (git-diff-model-generation model))) (repo (git-diff-model-repo model)) (snapshot (git-diff-model-snapshot model)) (state (if reset? #{ state: idle: pages: '() next-cursor: #f complete: #f } (git-diff-model-state model)))) (vector-set! model 2 generation) (emit! model (git-diff-reduce state #{ event: begin: generation: generation })) (source-git-request (vector-ref model 3) "git/diff" #{ repo: (vector-ref model 4) snapshot: (vector-ref model 5) kind: (vector-ref model 6) target: (vector-ref model 7) cursor: cursor max-lines: 400 max-bytes: 262144 } (lambda (page) (if (= generation (git-diff-model-generation model)) (if (valid-page? model page) (emit! model (git-diff-reduce (git-diff-model-state model) #{ event: page: generation: generation page: page })) (emit! model (git-diff-reduce (git-diff-model-state model) #{ event: error: error: operation-failed: }))) (cancel-page! model page repo snapshot))) (lambda (error) (when (= generation (git-diff-model-generation model)) (emit! model (git-diff-reduce (git-diff-model-state model) #{ event: error: error: error }))))) generation)) (define (git-diff-model-load! model) (cancel-current! model) (request! model #f #t)) (define (git-diff-model-more! model) (let ((state (git-diff-model-state model))) (if (or (dict-ref state rebasing: #f) (eq? (dict-ref state state: #f) stale:) (dict-ref state complete: #f) (not (dict-ref state next-cursor: #f))) (git-diff-model-generation model) (request! model (dict-ref state next-cursor: #f) #f)))) (define (git-diff-model-invalidate! model) (cancel-current! model) (vector-set! model 2 (+ 1 (git-diff-model-generation model))) (emit! model #{ state: idle: pages: '() next-cursor: #f complete: #f }) (git-diff-model-generation model)) ;; Keep a bounded presentation while revoking its authority and cursor. ;; A hidden review can wait until selected; no patch request is needed here. (define (git-diff-model-defer! model repo snapshot) (cancel-current! model) (vector-set! model 2 (+ 1 (git-diff-model-generation model))) (vector-set! model 4 repo) (vector-set! model 5 snapshot) (emit! model (dict-set (dict-set (dict-set (git-diff-model-state model) state: deferred:) next-cursor: #f) rebasing: #t))) (define (git-diff-model-resume! model) (when (eq? (dict-ref (git-diff-model-state model) state: #f) deferred:) (request! model #f #f))) (define (git-diff-model-reload! model repo snapshot) (git-diff-model-defer! model repo snapshot) (git-diff-model-resume! model))))packages/slate/src/slate/git/diff-view.sgladded
;;; (slate git diff-view) — pure bounded projection and navigation.(define-library (slate git diff-view) (import (sigil core) (sigil math) (slate git diff-model) (slate git model)) (export git-diff-model git-diff-rows git-diff-selected-row git-diff-move git-diff-view-name) (begin (define mounted-row-ceiling 800) (define (git-diff-model v) (dict-ref v slate/git-diff-model: #f)) (define (take xs n) (if (or (= n 0) (null? xs)) '() (cons (car xs) (take (cdr xs) (- n 1))))) (define (git-diff-rows v) (let* ((model (git-diff-model v)) (state (if model (git-diff-model-state model) #{ pages: '() })) (lines (take (git-diff-lines state) (- mounted-row-ceiling 1))) (rows (let loop ((rest lines) (i 0) (out '())) (if (null? rest) (reverse out) (loop (cdr rest) (+ i 1) (cons (dict-set (car rest) id: (string-append "diff-line:" (number->string i))) out)))))) (if (and (not (dict-ref state complete: #f)) (dict-ref state next-cursor: #f)) (append rows (list #{ id: "diff-more" kind: continuation: text: "Load more…" })) rows))) (define (git-diff-selected-row v) (let ((id (dict-ref v slate/git-selected: #f))) (let loop ((rows (git-diff-rows v))) (cond ((null? rows) #f) ((and id (string=? id (dict-ref (car rows) id: ""))) (car rows)) (else (loop (cdr rows))))))) (define (git-diff-move v delta) (let* ((rows (git-diff-rows v)) (selected (dict-ref v slate/git-selected: #f))) (if (null? rows) v (let loop ((rest rows) (i 0) (found 0)) (cond ((null? rest) (dict-set v slate/git-selected: (dict-ref (list-ref rows (max 0 (min (- (length rows) 1) (+ found delta)))) id: ""))) ((and selected (string=? selected (dict-ref (car rest) id: ""))) (loop (cdr rest) (+ i 1) i)) (else (loop (cdr rest) (+ i 1) found))))))) (define (git-diff-view-name repo kind target) (let ((key (git-repository-key repo))) (and key (string-append "*git-diff:" key ":" kind ":" (cond ((string? target) target) ((dict? target) (string-append (dict-ref target path: "?") "@" (number->string (dict-ref target stage: 0)))) (else "*")) "*"))))))packages/slate/src/slate/git/log-model.sgladded
;;; (slate git log-model) — one bounded history page and one lazy review.(define-library (slate git log-model) (import (sigil io) (sigil math) (sigil string) (sigil json) (slate provider) (slate git model) (slate git diff-model)) (export make-git-log-model git-log-model-state git-log-model-repo git-log-model-revision git-log-model-load! git-log-model-refresh! git-log-model-page! git-log-model-inspect! git-log-model-dispose! git-history-oid? git-log-commits) (begin (define *revision* 0) (define (git-log-model-revision model) (vector-ref model 7)) ;; tag, state, page generation, source, repository, listener, show generation (define (make-git-log-model source repo listener) (vector 'git-log #{ state: idle: commits: '() offset: 0 review: #f } 0 source repo listener 0 0 #f #f)) (define (git-log-model-state model) (vector-ref model 1)) (define (git-log-model-repo model) (vector-ref model 4)) (define (as-list value) (cond ((array? value) (array->list value)) ((list? value) value) (else '()))) (define (git-log-commits model) (as-list (dict-ref (git-log-model-state model) commits: '()))) (define (git-history-oid? value) (and (string? value) (member (string-length value) '(40 64)) (let loop ((i 0)) (or (= i (string-length value)) (and (string-contains? "0123456789abcdef" (substring value i (+ i 1))) (loop (+ i 1))))))) (define (emit! model state) (set! *revision* (+ *revision* 1)) (vector-set! model 7 *revision*) (vector-set! model 1 state) (let ((listener (vector-ref model 5))) (when listener (listener state))) state) (define (valid-reply? model reply) (and (dict? reply) (= (dict-ref reply version: 0) 1) (git-repository=? (git-log-model-repo model) (dict-ref reply repo: #{})) (dict? (dict-ref reply snapshot: #f)) (<= (bytevector-length (string->utf8 (json-encode reply))) 262144))) (define (valid-commit? commit) (and (dict? commit) (git-history-oid? (dict-ref commit oid: #f)) (string? (dict-ref commit subject: #f)) (let loop ((parents (as-list (dict-ref commit parents: '())))) (or (null? parents) (and (git-history-oid? (car parents)) (loop (cdr parents))))))) (define (cancel-review! model) (vector-set! model 9 #f) (vector-set! model 6 (+ 1 (vector-ref model 6))) (let* ((review (dict-ref (git-log-model-state model) review: #f)) (diff (and review (dict-ref review diff: #f)))) (when diff (git-diff-model-invalidate! diff)))) (define (request! model cursor) (let ((generation (+ 1 (vector-ref model 2)))) (vector-set! model 2 generation) (emit! model (dict-set (git-log-model-state model) state: loading:)) (source-git-request (vector-ref model 3) "git/log" #{ repo: (git-log-model-repo model) limit: 50 cursor: cursor } (lambda (reply) (when (= generation (vector-ref model 2)) (let ((commits (as-list (dict-ref reply commits: '())))) (if (and (valid-reply? model reply) (<= (length commits) 50) (let loop ((rest commits)) (or (null? rest) (and (valid-commit? (car rest)) (loop (cdr rest)))))) (emit! model (dict-set (dict-set reply state: ready:) review: (dict-ref (git-log-model-state model) review: #f))) (emit! model (dict-set (git-log-model-state model) state: error:)))))) (lambda (error) (when (= generation (vector-ref model 2)) (emit! model (dict-set (dict-set (git-log-model-state model) state: error:) error: error))))))) (define (git-log-model-load! model) (cancel-review! model) (emit! model (dict-set (git-log-model-state model) review: #f)) (request! model #f)) ;; Ref-only mutations keep the page and explicitly opened commit review. (define (git-log-model-refresh! model) (let* ((state (git-log-model-state model)) (offset (dict-ref state offset: 0))) (request! model (and (> offset 0) #{ head-oid: (dict-ref (dict-ref state snapshot: #{}) head-oid: "") offset: offset })))) (define (git-log-model-page! model direction) (let* ((state (git-log-model-state model)) (offset (dict-ref state offset: 0)) (cursor (if (> direction 0) (dict-ref state next-cursor: #f) (and (> offset 0) #{ head-oid: (dict-ref (dict-ref state snapshot: #{}) head-oid: "") offset: (max 0 (- offset 50)) })))) (when (and cursor (not (eq? (dict-ref state state: #f) loading:))) (request! model cursor)))) ;; At most one metadata request is in flight; rapid inspection retains ;; only the newest requested commit. Generation checks still guard replies. (define (git-log-model-inspect! model oid) (when (git-history-oid? oid) (cancel-review! model) (vector-set! model 9 oid) (emit! model (dict-set (git-log-model-state model) review: #{ state: loading: oid: oid })) (inspect-next! model))) (define (inspect-next! model) (when (and (not (vector-ref model 8)) (vector-ref model 9)) (let ((generation (vector-ref model 6)) (oid (vector-ref model 9))) (vector-set! model 8 #t) (vector-set! model 9 #f) (source-git-request (vector-ref model 3) "git/show" #{ repo: (git-log-model-repo model) oid: oid } (lambda (reply) (vector-set! model 8 #f) (when (= generation (vector-ref model 6)) (let ((commit (dict-ref reply commit: #f))) (if (and (valid-reply? model reply) (valid-commit? commit) (equal? oid (dict-ref commit oid: #f))) (let ((diff (make-git-diff-model (vector-ref model 3) (git-log-model-repo model) (dict-ref reply snapshot: #{}) "commit" oid (lambda (ignored) (when (= generation (vector-ref model 6)) (emit! model (git-log-model-state model))))))) (emit! model (dict-set (git-log-model-state model) review: #{ state: ready: oid: oid commit: commit diff: diff })) (git-diff-model-load! diff)) (emit! model (dict-set (git-log-model-state model) review: #{ state: error: oid: oid }))))) (inspect-next! model)) (lambda (error) (vector-set! model 8 #f) (when (= generation (vector-ref model 6)) (emit! model (dict-set (git-log-model-state model) review: #{ state: error: oid: oid error: error }))) (inspect-next! model)))))) (define (git-log-model-dispose! model) (vector-set! model 2 (+ 1 (vector-ref model 2))) (cancel-review! model) (emit! model #{ state: idle: commits: '() offset: 0 review: #f }))))packages/slate/src/slate/git/log-view.sgladded
;;; (slate git log-view) — stable selection and bounded ancestry lanes.(define-library (slate git log-view) (import (sigil math) (slate git model) (slate git log-model)) (export git-log-model git-log-selected git-log-move git-log-view-name git-log-graph) (begin (define (git-log-model v) (dict-ref v slate/git-log-model: #f)) (define (git-log-view-name repo) (let ((key (git-repository-key repo))) (and key (string-append "*git-log:" key "*")))) (define (git-log-selected v) (let* ((model (git-log-model v)) (commits (if model (git-log-commits model) '())) (selected (dict-ref v slate/git-selected: #f))) (or (find (lambda (commit) (equal? selected (dict-ref commit oid: #f))) commits) (and (pair? commits) (car commits))))) (define (git-log-move v delta) (let* ((commits (git-log-commits (git-log-model v))) (selected (git-log-selected v))) (if (not selected) v (let loop ((rest commits) (i 0)) (if (eq? (car rest) selected) (dict-set v slate/git-selected: (dict-ref (list-ref commits (max 0 (min (- (length commits) 1) (+ i delta)))) oid: "")) (loop (cdr rest) (+ i 1))))))) (define (index-of value values) (let loop ((rest values) (i 0)) (cond ((null? rest) #f) ((equal? value (car rest)) i) (else (loop (cdr rest) (+ i 1)))))) (define (take values n) (if (or (null? values) (= n 0)) '() (cons (car values) (take (cdr values) (- n 1))))) (define (unique values) (let loop ((rest values) (out '())) (cond ((null? rest) (reverse out)) ((member (car rest) out) (loop (cdr rest) out)) (else (loop (cdr rest) (cons (car rest) out)))))) ;; Graph lanes are bounded to 12; full parent IDs remain available in the ;; inspector when an unusually wide topology exceeds the visual lanes. (define (git-log-graph commits) (let loop ((rest commits) (lanes '()) (out '())) (if (null? rest) (reverse out) (let* ((commit (car rest)) (oid (dict-ref commit oid: "")) (parents-value (dict-ref commit parents: '())) (parents (if (array? parents-value) (array->list parents-value) parents-value)) (before (if (member oid lanes) lanes (append lanes (list oid)))) (lane (index-of oid before)) (next (take (unique (fold-left (lambda (xs id) (append xs (if (equal? id oid) parents (list id)))) '() before)) 12)) (edges (filter (lambda (edge) (number? (cdr edge))) (append (map (lambda (id) (cons (index-of id before) (index-of id next))) (filter (lambda (id) (not (equal? id oid))) before)) (map (lambda (id) (cons lane (index-of id next))) parents))))) (loop (cdr rest) next (cons #{ commit: commit lane: lane before: (length before) after: (length next) edges: edges } out))))))))packages/slate/src/slate/git/model.sgladded
;;; (slate git model) — provider-neutral Git repository/status client state.;;;;;; This module owns no transport and no view. A source selects its registered;;; root/provider, and the provider lowers `git/discover` / `git/status` onto the;;; node which owns that root. Request generations make late callbacks inert.(define-library (slate git model) (import (sigil string) (slate provider) (slate root) (slate project)) (export make-git-status-model git-status-model-state git-status-model-generation git-status-model-load! git-status-model-refresh! git-status-model-watch! git-status-model-watch-event! git-status-model-dispose! git-status-model-mutate! git-status-model-mutate-hunk! git-status-model-lock! git-status-model-unlock! git-status-reduce git-repository-key git-repository=? git-snapshot=? git-snapshot-key git-row-id) (begin ;; A model is [tag state generation listener]. State values are immutable ;; dicts, so transitions stay independently testable through the reducer. (define (make-git-status-model on-change) ;; [tag state generation listener refresh-in-flight? dirty? source project ;; unsubscribe-thunks watched-repository-key] (vector 'git-status-model #{ state: idle: generation: 0 } 0 on-change #f #f #f #f '() #f)) (define (git-status-model-state model) (vector-ref model 1)) (define (git-status-model-generation model) (vector-ref model 2)) (define (emit! model state) (vector-set! model 1 state) (let ((listener (vector-ref model 3))) (when listener (listener state))) state) (define (git-status-reduce state event) (let ((kind (dict-ref event event: error:))) (cond ((eq? kind begin:) (let ((previous (dict-ref state data: #f))) (if previous #{ state: stale: generation: (dict-ref event generation: 0) data: previous } #{ state: loading: generation: (dict-ref event generation: 0) }))) ((eq? kind ready:) #{ state: ready: generation: (dict-ref event generation: 0) data: (dict-ref event data: #{}) }) ((eq? kind unavailable:) #{ state: unavailable: generation: (dict-ref event generation: 0) reason: (dict-ref event reason: git-unavailable:) }) (else (let ((previous (dict-ref state data: #f))) (if previous #{ state: error: generation: (dict-ref event generation: 0) error: (dict-ref event error: operation-failed:) data: previous } #{ state: error: generation: (dict-ref event generation: 0) error: (dict-ref event error: operation-failed:) })))))) (define (string-field d key) (let ((value (and (dict? d) (dict-ref d key #f)))) (and (string? value) value))) (define (git-repository-key repo) (let ((root (and (dict? repo) (dict-ref repo root: #f))) (worktree (string-field repo worktree:)) (common-dir (string-field repo common-dir:))) (and root worktree common-dir (string-append (if (keyword? root) (keyword->string root) root) ":" worktree "\n" common-dir)))) (define (git-repository=? a b) (let ((ak (git-repository-key a)) (bk (git-repository-key b))) (and ak bk (string=? ak bk)))) (define (git-snapshot=? a b) (and (dict? a) (dict? b) (equal? (dict-ref a head-oid: #f) (dict-ref b head-oid: #f)) (equal? (dict-ref a index-id: #f) (dict-ref b index-id: #f)) (equal? (dict-ref a worktree-id: #f) (dict-ref b worktree-id: #f)))) (define (git-snapshot-key snapshot) (and (dict? snapshot) (let ((head (dict-ref snapshot head-oid: #f)) (index (dict-ref snapshot index-id: #f)) (worktree (dict-ref snapshot worktree-id: #f))) (and (string? head) (string? index) (string? worktree) (string-append head "\n" index "\n" worktree))))) (define (git-row-id repo snapshot entry) (let ((rk (git-repository-key repo)) (eid (and (dict? entry) (dict-ref entry path: (dict-ref entry id: #f))))) ;; Node entry IDs include the snapshot and are mutation authority. ;; Presentation instead follows the repository-relative path. The host ;; adds its section so staged and worktree rows remain independent. (and rk (string? eid) (string-append rk "\n" eid)))) (define (path-normalize path) (cond ((or (not path) (string=? path "")) "/") ((string=? path "/") "/") ((char=? (string-ref path (- (string-length path) 1)) #\/) (path-normalize (substring path 0 (- (string-length path) 1)))) (else path))) (define (path-parent path) (let ((path (path-normalize path))) (if (string=? path "/") "/" (let loop ((i (- (string-length path) 1))) (cond ((<= i 0) "/") ((char=? (string-ref path i) #\/) (substring path 0 i)) (else (loop (- i 1)))))))) (define (source-directory source) (let ((address (and source (dict-ref source address: #f)))) (and (string? address) (dict-set (dict-set source kind: dir:) address: (if (eq? (dict-ref source kind: file:) dir:) (path-normalize address) (path-parent address)))))) (define (discovery-source source current-project) (if (and current-project (project-source? current-project source)) (project-root current-project) (source-directory source))) (define (git-capable? source) (let* ((provider-name (and source (dict-ref source provider: #f))) (provider (and provider-name (provider-ref provider-name)))) (and provider (dict-ref provider git-request: #f) #t))) (define (error-code error) (if (dict? error) (dict-ref error code: operation-failed:) error)) (define (unavailable-error? error) (let ((code (error-code error))) (or (eq? code git-unavailable:) (and (string? code) (string=? code "git-unavailable"))))) (define (valid-discovery? result) (let ((node-repo (and (dict? result) (dict-ref result node-repo: #f)))) (and (= (dict-ref result version: 0) 1) (string-field node-repo worktree:) (string-field node-repo common-dir:)))) (define (valid-status? result repo) (and (dict? result) (= (dict-ref result version: 0) 1) (git-repository=? (dict-ref result repo: #{}) repo) (git-snapshot-key (dict-ref result snapshot: #f)))) (define (transition-error! model generation error) (when (= generation (git-status-model-generation model)) (emit! model (git-status-reduce (git-status-model-state model) (if (unavailable-error? error) #{ event: unavailable: generation: generation reason: git-unavailable: } #{ event: error: generation: generation error: error }))) (watch-settle! model))) (define (watch-settle! model) (when (vector-ref model 4) (vector-set! model 4 #f) (when (vector-ref model 5) (vector-set! model 5 #f) ;; An event can arrive after Git read its snapshot. Keep the returned ;; data, but conservatively mark it stale without starting more work. (when (eq? (dict-ref (git-status-model-state model) state: #f) ready:) (git-status-model-watch-event! model (vector-ref model 6) (vector-ref model 7)))))) (define (git-status-model-load! model source current-project) (let* ((generation (+ 1 (git-status-model-generation model))) (target (discovery-source source current-project)) (root (and target (source-root target)))) (vector-set! model 2 generation) (emit! model (git-status-reduce (git-status-model-state model) #{ event: begin: generation: generation })) (if (or (not target) (not root) (not (git-capable? target))) (begin (emit! model (git-status-reduce (git-status-model-state model) #{ event: unavailable: generation: generation reason: git-unavailable: })) (watch-settle! model)) (source-git-request target "git/discover" #{ path: (dict-ref target address: "/") } (lambda (discovery) (when (= generation (git-status-model-generation model)) (if (not (valid-discovery? discovery)) (transition-error! model generation operation-failed:) (let* ((node-repo (dict-ref discovery node-repo: #{})) (repo #{ root: (root-name-string root) worktree: (dict-ref node-repo worktree: "") common-dir: (dict-ref node-repo common-dir: "") })) (source-git-request target "git/status" #{ repo: repo recent-limit: 20 } (lambda (status) (when (= generation (git-status-model-generation model)) (if (valid-status? status repo) (begin (emit! model (git-status-reduce (git-status-model-state model) #{ event: ready: generation: generation data: status })) (watch-settle! model)) (transition-error! model generation operation-failed:)))) (lambda (error) (transition-error! model generation error))))))) (lambda (error) (transition-error! model generation error)))) generation)) (define (git-status-model-refresh! model source current-project) (let ((state (git-status-model-state model))) (if (or (vector-ref model 4) (eq? (dict-ref state state: #f) loading:) (eq? (dict-ref state mutation: #f) pending:)) (git-status-model-generation model) (begin (vector-set! model 4 #t) (vector-set! model 5 #f) (vector-set! model 6 source) (vector-set! model 7 current-project) (git-status-model-load! model source current-project))))) ;; Watchers are advisory. External filesystem activity marks the current ;; snapshot stale, but only an explicit user refresh starts Git work. This ;; preserves expansion/selection during review and prevents build-output ;; storms from monopolizing the node request lane. (define (git-status-model-watch-event! model source current-project) (vector-set! model 6 source) (vector-set! model 7 current-project) ;; A mutation response already contains an authoritative fresh status. ;; Ignore watcher noise caused by that mutation while it is pending: a ;; competing load would advance the generation and make the mutation ;; response inert, leaving stage/unstage apparently stuck. (cond ((eq? (dict-ref (git-status-model-state model) mutation: #f) pending:) #t) ((vector-ref model 4) ;; Preserve the refreshing mode line until the explicit request ends. (vector-set! model 5 #t)) (else (let* ((state (git-status-model-state model)) (data (dict-ref state data: #f))) (when data (emit! model #{ state: stale: generation: (git-status-model-generation model) data: data external-change?: #t }))))) #t) (define (git-status-model-watch! model source current-project repo) (let ((key (git-repository-key repo))) (unless (and key (equal? key (vector-ref model 9))) (for-each (lambda (stop) (when stop (stop))) (vector-ref model 8)) (vector-set! model 9 key) (vector-set! model 8 (if (not key) '() (map (lambda (path) (source-subscribe (dict-set (dict-set source kind: dir:) address: path) (lambda (event) (git-status-model-watch-event! model source current-project)))) (list (dict-ref repo worktree: "") (dict-ref repo common-dir: ""))))))) #t) (define (git-status-model-dispose! model) (for-each (lambda (stop) (when stop (stop))) (vector-ref model 8)) (vector-set! model 8 '()) (vector-set! model 9 #f) (vector-set! model 4 #f) (vector-set! model 5 #f) (vector-set! model 2 (+ 1 (vector-ref model 2))) #t) ;; Repository operations (such as switching branches) share the model's ;; mutation exclusion and install their authoritative returned status. (define (git-status-model-lock! model) (let ((before (git-status-model-state model))) (if (or (vector-ref model 4) (eq? (dict-ref before state: #f) loading:) (eq? (dict-ref before mutation: #f) pending:)) #f (let ((token (vector model (git-status-model-generation model) before))) (emit! model (dict-set before mutation: pending:)) token)))) (define (git-status-model-unlock! token fresh) (when token (let* ((model (vector-ref token 0)) (generation (vector-ref token 1)) (before (vector-ref token 2)) (repo (dict-ref (dict-ref before data: #{}) repo: #f))) (when (= generation (git-status-model-generation model)) (if (valid-status? fresh repo) (emit! model #{ state: ready: generation: generation data: fresh mutation: complete: }) (emit! model (dict-set (dict-set (dict-set before state: stale:) external-change?: #t) mutation: operation-failed:))))))) (define (git-status-model-mutate! model source verb entry-id on-ready on-error) (let* ((generation (git-status-model-generation model)) (state (git-status-model-state model)) (data (dict-ref state data: #f)) (repo (and data (dict-ref data repo: #f))) (snapshot (and data (dict-ref data snapshot: #f)))) (if (or (vector-ref model 4) (eq? (dict-ref state mutation: #f) pending:) (not repo) (not snapshot) (not (string? entry-id))) (when on-error (on-error operation-failed:)) (begin (emit! model (dict-set state mutation: pending:)) (source-git-request source verb #{ repo: repo expected-snapshot: snapshot entry-id: entry-id } (lambda (fresh) (when (= generation (git-status-model-generation model)) (if (valid-status? fresh repo) (begin (emit! model #{ state: ready: generation: generation data: fresh mutation: complete: changed-entry: entry-id }) (when on-ready (on-ready fresh))) (begin (emit! model (dict-set state mutation: operation-failed:)) (when on-error (on-error operation-failed:)))))) (lambda (error) (when (= generation (git-status-model-generation model)) (emit! model (dict-set state mutation: (error-code error))) (when on-error (on-error error))))))))) (define (git-status-model-mutate-hunk! model source verb hunk-id on-ready on-error) (let* ((generation (git-status-model-generation model)) (state (git-status-model-state model)) (data (dict-ref state data: #f)) (repo (and data (dict-ref data repo: #f))) (snapshot (and data (dict-ref data snapshot: #f)))) (if (or (vector-ref model 4) (eq? (dict-ref state mutation: #f) pending:) (not repo) (not snapshot) (not (dict? hunk-id))) (when on-error (on-error operation-failed:)) (begin (emit! model (dict-set state mutation: pending:)) (source-git-request source verb #{ repo: repo expected-snapshot: snapshot hunk-id: hunk-id } (lambda (fresh) (when (= generation (git-status-model-generation model)) (if (valid-status? fresh repo) (begin (emit! model #{ state: ready: generation: generation data: fresh mutation: complete: changed-entry: (dict-ref hunk-id file-id: #f) }) (when on-ready (on-ready fresh))) (begin (emit! model (dict-set state mutation: operation-failed:)) (when on-error (on-error operation-failed:)))))) (lambda (error) (when (= generation (git-status-model-generation model)) (emit! model (dict-set state mutation: (error-code error))) (when on-error (on-error error)))))))))))packages/slate/src/slate/git/review.sgladded
;;; Review continuity is presentation policy, independent of request authority.(define-library (slate git review) (import (sigil math) (sigil string) (slate components diff) (slate git status-view)) (export git-review-status git-review-diff) (begin (define (row-id row) (dict-ref row id: #f)) (define (row-for rows id) (and id (find (lambda (row) (equal? id (row-id row))) rows))) (define (owner row) (dict-ref row owner: (and (eq? (dict-ref row kind: #f) entry:) (row-id row)))) (define (hunk? row) (diff-kind=? (diff-row-kind row) hunk:)) (define (same-text? a b) (and (eq? (diff-row-kind a) (diff-row-kind b)) (equal? (dict-ref a text: #f) (dict-ref b text: #f)))) (define (position rows id) (let loop ((rest rows) (i 0)) (cond ((null? rest) 0) ((equal? id (row-id (car rest))) i) (else (loop (cdr rest) (+ i 1)))))) (define (near rows old-rows id) (and (pair? rows) (list-ref rows (min (- (length rows) 1) (position old-rows id))))) ;; Prefer the same hunk/line, then a nearby hunk in that file. When the ;; file leaves its section, continue with the next file in that section. ;; This operates on the current view, so navigation during a request wins. (define (reselect v before) (let* ((wanted (dict-ref v slate/git-selected: #f)) (old (row-for before wanted)) (rows (git-status-rows v)) (exact (row-for rows wanted)) (own (and old (owner old))) (entry (and own (row-for before own))) (section (and entry (dict-ref entry section: #f))) (same-owner (lambda (row) (and own (equal? own (owner row))))) (same-section (lambda (row) (and (eq? (dict-ref row kind: #f) entry:) (equal? section (dict-ref row section: #f))))) (hunks (filter (lambda (row) (and (same-owner row) (hunk? row))) rows)) (old-hunks (filter (lambda (row) (and (same-owner row) (hunk? row))) before)) (target (or (and exact (or (not own) (not (dict-ref old owner: #f)) (same-text? old exact)) exact) (and old own (find (lambda (row) (and (same-owner row) (same-text? old row))) rows)) (and (dict-ref (or old #{}) owner: #f) (near hunks old-hunks wanted)) (row-for rows own) (and section (near (filter same-section rows) (filter same-section before) own)) (and entry (find (lambda (row) (and (eq? (dict-ref row kind: #f) entry:) (equal? (dict-ref (dict-ref row entry: #{}) path: #f) (dict-ref (dict-ref entry entry: #{}) path: #f)))) rows)) (and wanted (near (filter (lambda (row) (dict-ref row meaningful?: #f)) rows) (filter (lambda (row) (dict-ref row meaningful?: #f)) before) wanted))))) (dict-set (dict-set v slate/git-selected: (and target (row-id target))) slate/git-review-version: (+ 1 (dict-ref v slate/git-review-version: 0))))) (define (git-review-status v data) (let* ((before (git-status-rows v)) (next (dict-set v slate/git-data: data)) ;; Section folding hides rows, but does not close their reviews. (entries (git-status-rows (dict-set (dict-set next slate/git-folded: '()) slate/git-inline-models: '()))) (models (filter (lambda (pair) (row-for entries (car pair))) (dict-ref v slate/git-inline-models: '()))) (folded (filter (lambda (id) (any (lambda (pair) (string-starts-with? id (string-append (car pair) ":hunk:"))) models)) (dict-ref v slate/git-hunk-folded: '())))) (reselect (dict-set (dict-set next slate/git-inline-models: models) slate/git-hunk-folded: folded) before))) (define (hunk-body rows id) (let loop ((rest rows)) (cond ((null? rest) '()) ((equal? id (row-id (car rest))) (let collect ((tail (cdr rest)) (out '())) (if (or (null? tail) (hunk? (car tail)) (not (equal? (owner (car rest)) (owner (car tail))))) (reverse out) (collect (cdr tail) (cons (dict-ref (car tail) text: "") out))))) (else (loop (cdr rest)))))) (define (git-review-diff v id previous) (let* ((old-view (dict-set v slate/git-diff-state: (cons id previous))) (before (git-status-rows old-view)) (unfolded-old (git-status-rows (dict-set old-view slate/git-hunk-folded: '()))) (unfolded-new (git-status-rows (dict-set v slate/git-hunk-folded: '()))) (hunks (filter (lambda (row) (and (equal? id (owner row)) (hunk? row))) unfolded-new)) (selected (row-for unfolded-old (dict-ref v slate/git-selected: #f))) (selected-match (and selected (hunk? selected) (equal? id (owner selected)) (matching-hunk unfolded-old unfolded-new hunks (row-id selected)))) (folded (filter string? (map (lambda (fold) (let ((old (row-for unfolded-old fold))) (cond ((not (and old (equal? id (owner old)))) fold) ((row-for unfolded-new fold) fold) (else (let ((match (matching-hunk unfolded-old unfolded-new hunks fold))) (and match (row-id match))))))) (dict-ref v slate/git-hunk-folded: '()))))) (reselect (dict-set (if selected-match (dict-set v slate/git-selected: (row-id selected-match)) v) slate/git-hunk-folded: folded) before))) (define (matching-hunk before after hunks id) (or (row-for after id) (let* ((body (hunk-body before id)) (matches (filter (lambda (row) (equal? body (hunk-body after (row-id row)))) hunks))) ;; Repeated or partial bodies are ambiguous. (and (pair? body) (= (length matches) 1) (car matches)))))))packages/slate/src/slate/git/status-view.sgladded
;;; (slate git status-view) — pure row, selection, and folding projection.(define-library (slate git status-view) (import (sigil core) (sigil math) (sigil string) (slate components diff) (slate git diff-model) (slate git model)) (export git-status-model git-status-rows git-status-move git-status-toggle git-status-selected-row git-status-view-name git-status-repository-view-name make-git-status-row-cache git-status-row-revision) (begin (define mounted-row-ceiling 800) (define (take xs n) (if (or (= n 0) (null? xs)) '() (cons (car xs) (take (cdr xs) (- n 1))))) (define (git-status-view-name source) (let ((root (dict-ref source root: (dict-ref source provider: unknown:)))) (string-append "*git-status:" (if (keyword? root) (keyword->string root) (if (string? root) root "unknown")) ":" (dict-ref source address: "/") "*"))) (define (git-status-repository-view-name repo) (let ((key (git-repository-key repo))) (and key (string-append "*git-status:" key "*")))) (define (git-status-model v) (dict-ref v slate/git-model: #f)) (define (model-state v) (let ((model (git-status-model v))) (if model (git-status-model-state model) #{ state: loading: }))) (define (status-data v) (dict-ref v slate/git-data: (dict-ref (model-state v) data: #{}))) (define (as-list value) (cond ((array? value) (array->list value)) ((list? value) value) (else '()))) (define (entry-section entry) (let ((kind (dict-ref entry kind: "ordinary")) (index (dict-ref entry index-state: ".")) (worktree (dict-ref entry worktree-state: "."))) (cond ((or (string=? kind "conflict") (string=? index "U") (string=? worktree "U")) conflicts:) ((string=? kind "untracked") untracked:) ((not (string=? index ".")) staged:) (else unstaged:)))) (define (entry-in-section? entry key) (let ((kind (dict-ref entry kind: "ordinary")) (index (dict-ref entry index-state: ".")) (worktree (dict-ref entry worktree-state: "."))) (cond ((eq? key conflicts:) (or (string=? kind "conflict") (string=? index "U") (string=? worktree "U"))) ((eq? key untracked:) (string=? kind "untracked")) ((or (string=? kind "conflict") (string=? kind "untracked")) #f) ((eq? key staged:) (not (string=? index "."))) ((eq? key unstaged:) (not (string=? worktree "."))) (else #f)))) (define sections (list (cons conflicts: "Conflicts") (cons untracked: "Untracked") (cons unstaged: "Unstaged") (cons staged: "Staged"))) (define (section-id key) (string-append "section:" (keyword->string key))) (define (row-id data entry section) (string-append (or (git-row-id (dict-ref data repo: #{}) (dict-ref data snapshot: #{}) entry) (dict-ref entry id: (dict-ref entry path: "entry"))) "\n" (keyword->string section))) (define (entries-for entries key) (filter (lambda (entry) (entry-in-section? entry key)) entries)) (define (inline-model v id) (let ((found (assoc id (dict-ref v slate/git-inline-models: '())))) (and found (cdr found)))) (define (range-parts token) (let* ((body (substring token 1 (string-length token))) (parts (string-split body ","))) (cons (or (string->number (car parts)) 0) (if (null? (cdr parts)) 1 (or (string->number (cadr parts)) 0))))) (define (hunk-identity entry-id side line) (let* ((header (dict-ref line text: "")) (parts (string-split header " ")) (old (and (>= (length parts) 3) (range-parts (list-ref parts 1)))) (new (and old (range-parts (list-ref parts 2))))) (and old new #{ file-id: entry-id side: side old-start: (dict-ref line old-start: (car old)) old-lines: (dict-ref line old-lines: (cdr old)) new-start: (dict-ref line new-start: (car new)) new-lines: (dict-ref line new-lines: (cdr new)) context-hash: (dict-ref line context-hash: header) }))) (define (hunk-row-id owner identity) (and identity (string-append owner ":hunk:" (dict-ref identity side: "") ":" (number->string (dict-ref identity old-start: 0)) ":" (number->string (dict-ref identity new-start: 0)) ":" (dict-ref identity context-hash: "")))) (define (inline-rows v entry-id file-id side) (let ((model (inline-model v entry-id))) (if (not model) '() (let* ((override (dict-ref v slate/git-diff-state: #f)) (state (if (and override (equal? (car override) entry-id)) (cdr override) (git-diff-model-state model))) (kind (dict-ref state state: idle:)) (authoritative? (and (not (dict-ref state rebasing: #f)) (member kind (list ready: stale:)) (git-snapshot=? (git-diff-model-snapshot model) (dict-ref (status-data v) snapshot: #{})))) (folded (dict-ref v slate/git-hunk-folded: '()))) (cond ((or (eq? kind idle:) (eq? kind loading:) (and (eq? kind deferred:) (null? (git-diff-lines state)))) (list #{ id: (string-append entry-id ":loading") kind: loading: text: "Loading diff…" meaningful?: #f owner: entry-id })) ((and (eq? kind error:) (null? (git-diff-lines state))) (list #{ id: (string-append entry-id ":error") kind: error: text: "Diff failed or became stale." meaningful?: #f owner: entry-id review-state: error: })) (else (let loop ((lines (git-diff-lines state)) (index 0) (hidden? #f) (out '())) (if (null? lines) (reverse (if (and (not (dict-ref state complete: #f)) (dict-ref state next-cursor: #f)) (cons #{ id: (string-append entry-id ":more") kind: continuation: text: "Load more…" meaningful?: #t owner: entry-id } out) out)) (let* ((line (car lines)) (line-kind (diff-row-kind line)) (identity (and (diff-kind=? line-kind hunk:) (hunk-identity file-id side line))) (hid (hunk-row-id entry-id identity)) (next-hidden (if hid (if (member hid folded) #t #f) hidden?)) (id (or hid (string-append entry-id ":line:" (number->string index)))) (row (dict-set (dict-set (dict-set (dict-set (dict-set line kind: line-kind) id: id) owner: entry-id) hunk-id: (and authoritative? identity)) meaningful?: #t)) (row (dict-set row review-state: kind)) (row (if hid (dict-set row folded?: next-hidden) row))) (loop (cdr lines) (+ index 1) next-hidden (if (and next-hidden (not hid)) out (cons row out)))))))))))) (define (git-status-rows-base v) (let* ((data (status-data v)) (entries (as-list (dict-ref data entries: '()))) (folded (dict-ref v slate/git-folded: '()))) (take (append (fold-left (lambda (out pair) (let* ((key (car pair)) (members (entries-for entries key)) (sid (section-id key))) (if (null? members) out (append out (list #{ id: sid kind: section: section: key label: (cdr pair) count: (length members) meaningful?: #t }) (if (member sid folded) '() (fold-left (lambda (rows entry) (let ((id (row-id data entry key))) (append rows (list #{ id: id kind: entry: section: key meaningful?: #t entry: entry expanded: (if (inline-model v id) #t #f) }) '()))) '() members)))))) '() sections) (let ((commits (as-list (dict-ref data recent-commits: '()))) (sid "section:recent-commits")) (if (null? commits) '() (append (list #{ id: sid kind: section: section: recent-commits: label: "Recent commits" count: (length commits) meaningful?: #t }) (if (member sid folded) '() (map (lambda (commit) #{ id: (string-append "commit:" (dict-ref commit oid: "unknown")) kind: commit: meaningful?: #t commit: commit }) commits)))))) mounted-row-ceiling))) ;; A view owns its bounded projection cache. Selection is deliberately not ;; an input: moving point changes neither rows nor mutation authority. ;; Models are mutable, so compare their current states and snapshots too. ;; Revisions are unique across view lifetimes, including replacing a view ;; for the same repository in a retained pane. (define *row-revision* 0) (define (make-git-status-row-cache) (vector #f '() #f)) (define (git-status-row-revision v) (git-status-rows v) (let ((cache (dict-ref v slate/git-row-cache: #f))) (and cache (vector-ref cache 2)))) (define (row-inputs v) (append (list (status-data v) (dict-ref v slate/git-folded: '()) (dict-ref v slate/git-hunk-folded: '()) (dict-ref v slate/git-inline-models: '()) (dict-ref v slate/git-diff-state: #f)) (fold-right (lambda (pair out) (cons (git-diff-model-state (cdr pair)) (cons (git-diff-model-snapshot (cdr pair)) out))) '() (dict-ref v slate/git-inline-models: '())))) (define (same-inputs? a b) (and (list? b) (cond ((null? a) (null? b)) ((null? b) #f) (else (and (eq? (car a) (car b)) (same-inputs? (cdr a) (cdr b))))))) (define (git-status-rows v) (let ((cache (dict-ref v slate/git-row-cache: #f))) (if (not cache) (project-rows v) (let ((inputs (row-inputs v))) (if (same-inputs? inputs (vector-ref cache 0)) (vector-ref cache 1) (let ((rows (project-rows v))) (set! *row-revision* (+ *row-revision* 1)) (vector-set! cache 0 inputs) (vector-set! cache 1 rows) (vector-set! cache 2 *row-revision*) rows)))))) (define (project-rows v) (let* ((base (git-status-rows-base v)) (expanded (fold-left (lambda (out row) (append out (list row) (if (and (eq? (dict-ref row kind: #f) entry:) (dict-ref row expanded: #f)) (inline-rows v (dict-ref row id: "") (dict-ref (dict-ref row entry: #{}) id: "") (if (eq? (dict-ref row section: #f) staged:) "staged" "worktree")) '()))) '() base))) (take expanded mounted-row-ceiling))) (define (git-status-selected-row v) (let ((wanted (dict-ref v slate/git-selected: #f))) (let loop ((rows (git-status-rows v))) (cond ((null? rows) #f) ((and wanted (string=? wanted (dict-ref (car rows) id: ""))) (car rows)) (else (loop (cdr rows))))))) (define (select-valid v) (if (git-status-selected-row v) v (let ((rows (filter (lambda (row) (dict-ref row meaningful?: #f)) (git-status-rows v)))) (if (null? rows) v (dict-set v slate/git-selected: (dict-ref (car rows) id: #f)))))) (define (git-status-move v delta) (let* ((v (select-valid v)) (rows (filter (lambda (row) (dict-ref row meaningful?: #f)) (git-status-rows v))) (wanted (dict-ref v slate/git-selected: #f))) (let loop ((rest rows) (index 0) (found 0)) (cond ((null? rest) (if (null? rows) v (dict-set v slate/git-selected: (dict-ref (list-ref rows (max 0 (min (- (length rows) 1) (+ found delta)))) id: #f)))) ((and wanted (string=? wanted (dict-ref (car rest) id: ""))) (loop (cdr rest) (+ index 1) index)) (else (loop (cdr rest) (+ index 1) found)))))) (define (git-status-toggle v) (let ((row (git-status-selected-row v))) (if (and row (eq? (dict-ref row kind: #f) section:)) (let* ((id (dict-ref row id: "")) (folded (dict-ref v slate/git-folded: '()))) (dict-set v slate/git-folded: (if (member id folded) (filter (lambda (x) (not (string=? x id))) folded) (cons id folded)))) v)))))packages/slate/src/slate/ledge.sglmodified
(span (@ (class "ledge-sep")) " ▸")))) (ledge-item id: 'echo slot: 'center order: 0 render: (lambda (s) `(span (@ (class ,(if (dict-ref s slate/echo-fading?: #f) "ledge-echo fading" "ledge-echo"))) `(span (@ (class "ledge-echo") (data-echo-generation ,(number->string (dict-ref s slate/echo-generation: 0)))) ,(dict-ref s slate/echo: "")))) ;; The first LIVE item: a wall-clock/date. mobile-hidden? by default (the ;; phone has its own clock — David). refresh-ms drives the metronome tick.packages/slate/src/slate/list.sglmodified
;;; (slate list) — small shared list operations missing from the pinned stdlib.(define-library (slate list) (import (sigil core)) (export list-last) (export list-last drop-last) (begin ;; Callers establish non-empty input; match the historical Slate helpers' ;; behavior rather than inventing an empty-list sentinel. (define (list-last xs) (if (null? (cdr xs)) (car xs) (list-last (cdr xs)))))) (if (null? (cdr xs)) (car xs) (list-last (cdr xs)))) (define (drop-last xs) (if (null? (cdr xs)) '() (cons (car xs) (drop-last (cdr xs)))))))packages/slate/src/slate/main.sglmodified
(define-library (slate main) (import (sigil core) (sigil io) (sigil list) (sigil math) (sigil dict) (sigil string) (sigil json) (sigil time) (sigil web client) (slate theme) (slate face) (slate command) (slate list) (slate hooks) (slate modes git-status) (slate modes git-diff) (slate modes git-log) (slate commands accessory) (slate commands appearance) (slate commands display) (slate commands enclave) (slate commands file) (slate commands folder) (slate folder enrichment) (slate commands git) (slate commands git branch) (slate commands help) (slate commands image) (slate commands pane) (slate modes web) (slate modes image) (slate modes welcome) (slate modes node-status) (slate notifications) (slate modes notifications) (slate modes agent-session) (slate boot) (slate main preferences)) (export main slate-refresh slate-dispatch notify slate-set-text slate-rehighlight slate-folder-take-motion slate-folder-preview-token slate-folder-preview-flush slate-rehighlight-window slate-editor-underlay-html slate-faces-css slate-editor-lines-html slate-editor-lines-text slate-buffer-line-count slate-editor-rev slate-editor-sync slate-unfold-at slate-test-fill slate-editor-sync slate-unfold-at slate-test-fill slate-test-agent-diff slate-compose-begin slate-compose-end slate-auto-pairs? slate-line-numbers slate-buffer-input slate-buffer-set-all slate-buffer-length slate-editor-value slate-editor-caret slate-editor-anchor slate-assist slate-set-cursor slate-accessory-key slate-accessory-key slate-accessory-modifier slate-copy-modifiers slate-test-key-event slate-selector-set slate-selector-set slate-selector-set-profile slate-term-take-frame slate-term-resize slate-term-scroll slate-term-paste slate-term-send-mouse slate-term-mouse-active slate-term-prediction-enabled slate-term-take-prediction-input slate-term-paste slate-term-compose slate-term-send-mouse slate-term-mouse-active slate-term-scroll-gesture slate-term-copy-snapshot slate-term-copy-end slate-term-received-clip slate-tmux-size ;; OSC-52 staged clipboard (§10): JS reads the staged text (take gesture) ;; and confirms a successful system-clipboard write. ;; the WEB kernel's entry points (the embedded-iframe view) slate-web-set-engaged slate-web-set-load-state slate-web-set-title slate-eval-to-string slate-client-update-take slate-client-update-available slate-performance-capture-take slate-performance-capture-report slate-performance-trace-set slate-performance-trace-poll slate-wake-lock-wanted slate-wake-lock-report slate-node-status-deliver slate-enclave-tick slate-enclave-active? slate-set-enclave-ws-url ;; R3a. Exported for the SAME reason as slate-node-retry below: the JS ;; bridge reaches these only through the eval channel, so without an ;; time in the shipped build only. slate-enclave-retry slate-enclave-link-state slate-enclave-set-ping-enabled slate-enclave-diag slate-echo-tick slate-ledge-tick slate-echo-animation-done slate-ledge-tick ;; the Lantern host bridge: JS glue entry points + the boot hook slate-lantern-available! slate-lantern-poll slate-lantern-deliver slate-lantern-event slate-lantern-boot slate-node-request-pair slate-node-retry slate-node-pair-exchange slate-node-connected? slate-agent-provider-secret-submit slate-node-blob-poll slate-node-blob-bytes slate-node-blob-ready slate-node-blob-poll slate-node-blob-bytes slate-node-blob-ready slate-node-blob-mime slate-node-image-via-blob ;; the browser-fs bridge (OPFS + FSAPI): JS glue entry points. Same ;; queue/tick/dispatch shape as the Lantern bridge, Resource plane only. ;; ---- sticky modifiers (design §6): fold armed [C]/[M] into the props ------- (define (sticky-mods state) (dict-ref state slate/sticky-mods: #{ ctrl: 'off alt: 'off })) ;; Use the normal modifier reducer without replacing focused DOM. The host ;; updates the chips, just as it does when consuming a one-shot modifier. (define (slate-accessory-modifier which) (if (or (string=? which "ctrl") (string=? which "alt")) (begin (set! *state* (handle *state* (string-append "sticky-mod:" which))) (symbol->string (dict-ref (sticky-mods *state*) (if (string=? which "ctrl") ctrl: alt:) 'off))) "")) (define (sticky-armed? s k) (let ((v (dict-ref s k 'off))) (or (eq? v 'once) (eq? v 'locked)))) ;; Fold armed sticky mods into a props dict (OR with any real modifier) so a ;; hardware, bar, or OSK key composes with them. Called at the slate-handle-key (s2 (if (eq? (dict-ref s2 alt: 'off) 'once) (dict-set s2 alt: 'off) s2))) (set! *state* (dict-set *state* slate/sticky-mods: s2)))) (define (slate-copy-modifiers) (let* ((mods (sticky-mods *state*)) (prefix (string-append (if (sticky-armed? mods ctrl:) "C-" "") (if (sticky-armed? mods alt:) "M-" "")))) (clear-once-sticky!) prefix)) ;; Prompt text input is owned here, at the same key-event boundary as prompt ;; commands. Relying on the browser's default insertion made the frame bridge ;; return fall-through without ever updating the app-db query. Editor typing (else (slate-dispatch res) #t)))))))))))) (define (slate-handle-key props) (slate-handle-key/core props slate-refresh)) ;; Direct prompt editing (printable keys, Backspace, confirm reminders) ;; changes only selector state. Commands still call slate-dispatch and take ;; the full refresh path, so accept/close/drill and every broader mutation ;; update both the frame and palette mounts. (slate-handle-key/core props (lambda () (if (minibuffer-open? *state*) (slate-selector-refresh) (slate-refresh))))) ;; The accessory bar's key path (design §3.3): a `key:` button whose spec the ;; JS shim couldn't self-insert (a named key or a modifier chord) is routed ;; (slate accessory)) and enter the SAME slate-handle-key — so a bound chord ;; runs its command, an armed prefix arms, and a terminal's `default:` catch-all ;; sends Esc/Tab/C-c/arrows to the pty with zero terminal-specific bar code. ;; Returns: "2" when a prefix is now ARMED (the JS routes the NEXT OSK key ;; through here too, so a chord like C-x 2 completes on a phone keyboard); "1" ;; when handled + done; "" on a fall-through (an unbound printable — the shim ;; self-inserts it). The prefix check comes first because arming returns #t too. ;; Returns a compact damage contract to the browser shim: ;; "1" handled without a Slate render (terminal byte delivery is the hot path) ;; "2" prefix armed without a render ;; "3" handled and Slate rendered ;; "4" prefix armed and Slate rendered ;; "5" handled without a Slate render, with terminal grid damage ;; "" unbound fall-through (the shim self-inserts it) ;; The distinction is load-bearing on mobile: treating every terminal character ;; as a render makes JS afterRender invalidate and repaint the entire terminal. (define (slate-accessory-key spec) (let ((r (slate-handle-key (spec->props spec)))) (cond ((dict-ref *state* slate/pending-prefix: #f) "2") (let* ((before *refresh-count*) (r (slate-handle-key (spec->props spec))) (prefix? (dict-ref *state* slate/pending-prefix: #f)) (rendered? (> *refresh-count* before)) (v (active-view *state*)) (term-damage? (and r (not rendered?) v (terminal? v) (terminal-frame-pending? (view-name v))))) (cond ((and prefix? rendered?) "4") (prefix? "2") ((and r rendered?) "3") (term-damage? "5") (r "1") (else "")))) (equal? reason "CONNECT_TIMEOUT"))) (define (note-link-lost!) (enclave-node-fail-inflight! "connection lost") ;; Ruling 4's process/node row: flip to UNKNOWN immediately, never keep ;; showing last-known-good as current. Every remote root rides the account ;; plane, so losing it means we can no longer see ANY of them -- and a dead ;; node displayed as alive is what invites a destructive act against it. ;; The rows keep their age ("machine · unknown · as of 40s ago") rather ;; than merely changing word. (roots-mark-unknown! node: (dict-ref *state* slate/now-ms: 0))) ;; Direct carriers are independent of the account plane. Fail and mark ;; only work which still depends on Enclave; a ready direct peer remains ;; current until that carrier itself reports closure. (enclave-node-account-lost! "connection lost" (dict-ref *state* slate/now-ms: 0))) (define (pairing-on-error! reason) ;; The session's own refusal dict when it has one (it carries the SERVER's ;; continuation, so it cannot be a static table entry (see open-confirm). (define (prompt-desc state id) (cond ((string=? id "git-branch") (git-branch-prompt state)) ((or (string=? id "describe-hook") (string=? id "describe-face") (string=? id "describe-action") (if (and (> (string-length (string-trim value)) 0) (<= (string-length value) 120)) #t "Use a title of 1–120 characters.")) (lambda (st value) (agent-settings-command st #{ title: value })))) ((string=? id "terminal-session") (dict-set (cr-prompt "Reattach terminal" terminal-session-candidates) candidate-policy: live:)) ((string=? id "agent-session") (cr-prompt "Open agent session" agent-session-candidates)) ((string=? id "theme") (cr-prompt "Switch theme" theme-candidates)) (dict-set (cr-prompt "Open web view" web-url-candidates) empty-action: weburl-empty-action)) ((string=? id "opacity") (cr-prompt "Set content opacity" (lambda (s) (amount-set-candidates s "opacity:" "%" (list 100 85 72 60 45))))) (dict-set (cr-prompt "Set content opacity" (lambda (s) (amount-set-candidates s "opacity:" "%" (list 100 85 72 60 45)))) candidate-policy: live:)) ((string=? id "chrome-opacity") (cr-prompt "Set chrome opacity" (lambda (s) (amount-set-candidates s "chrome-opacity:" "%" (list 100 90 86 78 65))))) (dict-set (cr-prompt "Set chrome opacity" (lambda (s) (amount-set-candidates s "chrome-opacity:" "%" (list 100 90 86 78 65)))) candidate-policy: live:)) ;; A server URL you retype every time you re-pair. Not a secret, and ;; recalling it is most of the value of this prompt. ((string=? id "enclave-host") ;; standard completion ranker; a prompt carrying its own `rank:` (today only ;; incremental search) uses that instead — see prompt-rank for why search cannot ;; ride rank-candidates. ;; ;; A completing-read snapshots its candidates by default. The cache is bounded ;; to ONE active prompt session: open-prompt increments slate/prompt-session:, ;; and the first render replaces the previous session's rows. File prompts use ;; their directory cache; explicitly live prompts rebuild from the query. (define *candidate-snapshot-token* #f) (define *candidate-snapshot-rows* '()) (define (effective-prompt-candidates state desc) (if (not (eq? (prompt-candidate-policy desc) snapshot:)) (prompt-candidates desc state) (let ((token (string-append (dict-ref state slate/minibuffer: "command") ":" (number->string (dict-ref state slate/prompt-session: 0))))) (if (and *candidate-snapshot-token* (string=? token *candidate-snapshot-token*)) *candidate-snapshot-rows* (let ((rows (prompt-candidates desc state))) (set! *candidate-snapshot-token* token) (set! *candidate-snapshot-rows* rows) rows))))) (define (ranked-candidates state) (let* ((desc (prompt-desc state (dict-ref state slate/minibuffer: "command"))) (query (dict-ref state slate/selector-query: "")) (cands (prompt-candidates desc state)) (cands (effective-prompt-candidates state desc)) (rank (prompt-rank desc))) (if rank (rank query cands) ;; see the FULL ranked set (a subset's common prefix is never shorter, so a capped ;; one would over-complete past the cap). Completing to the selection removed that ;; coupling — you cannot select a row you cannot see. (define *selector-max-rows* 200) ;; Rank the COMPLETE candidate set first, then materialize only a useful ;; navigation window. Fifty is already 5–7 mobile screens (and several ;; desktop panels); rendering 200 highlighted rows made the broad first few ;; query characters pay for 150 nodes nobody could see. (define *selector-max-rows* 50) (define (visible-candidates state) (take-at-most *selector-max-rows* (ranked-candidates state))) ;; candidates, so it renders its question and nothing else (and deliberately ;; not as a `.palette` — see render-confirm for why that would make it ;; unanswerable). Everything else is the text+list selector. (define (palette-ranked state desc query ranked) (let* ((id (dict-ref state slate/minibuffer: "command")) (idx (selector-clamp (dict-ref state slate/selector-index: 0) (length ranked))) (ea (prompt-empty-action desc state query))) (render-selector (prompt-crumbs state) query ranked idx ea id (let ((ring (prompt-history desc))) (and ring (> (history-count (history-rings state) ring) 0)))))) (define (palette state) (let* ((id (dict-ref state slate/minibuffer: "command")) (desc (prompt-desc state id))) (if (prompt-confirm? desc) (render-confirm (prompt-label desc)) (let* ((query (dict-ref state slate/selector-query: "")) (ranked (visible-candidates state)) (idx (selector-clamp (dict-ref state slate/selector-index: 0) (length ranked))) (ea (prompt-empty-action desc state query))) (ranked (visible-candidates state))) ;; Show the history control only when there is a ring AND it holds ;; something. Both halves matter: a prompt with history: #f (the ;; pairing code) must never advertise a history it is forbidden to ;; have, and a ring that is merely EMPTY would offer a button that ;; opens nothing — which teaches the user the control is useless ;; before they ever reach a prompt where it is not. (render-selector (prompt-crumbs state) query ranked idx ea id (let ((ring (prompt-history desc))) (and ring (> (history-count (history-rings state) ring) 0)))))))) (palette-ranked state desc query ranked))))) ;; The Ledge: the always-present bottom strip, now DATA-DRIVEN. The whole ;; strip is `slate/ledge-items:` (a list of ledge-item records); here we just #f (dict-ref state slate/acc-kbd-menu: #f)) ,(ledge state) ,@(if (dict-ref state slate/minibuffer: #f) (list (palette state)) '()) ,(accessory-keyboard-deck (active-view state) (sticky-mods state) (let ((v (active-view state))) (and v (terminal? v) (view-name v)))) ,@(let ((wk (dict-ref state slate/which-key: #f))) (if wk (list (render-which-key (dict-ref wk title: "") (dict-ref wk map: #{}))) (list (render-which-key (dict-ref wk title: "") (dict-ref wk map: #{}) (dict-ref wk labels: #{}))) '()))))) ;; ---- the render subscription -------------------------------------------- ;; afterRender post-pass — TERM.ensure/faces/etc.). Read by slate-node-tick to ;; decide its return signal; cheap (one integer bump per render). (define *refresh-count* 0) ;; The palette is a sibling mount, not a second state machine. It reads the ;; same *state*, uses the same pure palette renderer and routes keys through ;; the same slate-handle-key closure. Its own theme-variable boundary keeps ;; the overlay visually identical now that it no longer inherits .frame. (define (render-palette-mount/content state content) `(div (@ (class "palette-host") (style ,(string-append (theme->css-vars (active-theme state)) (translucency-css-vars state) (text-css-vars state) (font-css-vars state))) (on-key: ,slate-handle-key)) ,@content)) (define (render-palette-mount state) (render-palette-mount/content state (if (dict-ref state slate/minibuffer: #f) (list (palette state)) '()))) (define (slate-selector-refresh) (web-client-update "#palette-root" (lambda () (render-palette-mount *state*))) "") ;; Folder point motion updates only the two row markers in the DOM. The ;; listing remains owned by the normal renderer, rebuilt when point leaves ;; its rendered window or anything besides point changes. (define *folder-motion* #f) (define (folder-motion-state? before after old new) (and (= (length (dict-keys before)) (length (dict-keys after))) (list-all? (lambda (key) (or (eq? key slate/views:) (eq? key slate/command-mru:) (eq? (dict-ref before key #f) (dict-ref after key #f)))) (dict-keys before)) (let loop ((a (views before)) (b (views after))) (cond ((null? a) (null? b)) ((null? b) #f) ((or (eq? (car a) (car b)) (and (eq? (car a) old) (eq? (car b) new))) (loop (cdr a) (cdr b))) (else #f))))) (define (folder-motion-refresh! before after event) (let ((old (active-view before)) (new (active-view after))) (and (or (string=? event "folder-next") (string=? event "folder-prev")) (not (minibuffer-open? before)) (not (dict-ref before slate/prefix-arg: #f)) (folder-point-only-change? old new) (folder-point-rendered? new) (folder-motion-state? before after old new) (begin (refresh-folder-enrichment! after (current-second) slate-refresh) (set! *folder-motion* #{ name: (view-name new) point: (folder-point new) pending: (if (folder-detail new preview-start: #f) #t #f) }) #t)))) (define (slate-folder-take-motion) (let ((motion *folder-motion*)) (set! *folder-motion* #f) (if motion (json-encode motion) ""))) (define (slate-folder-preview-token) (folder-preview-request-token)) (define (slate-folder-preview-flush token) (flush-folder-preview! token) (slate-refresh)) (define (slate-refresh) (set! *folder-motion* #f) (refresh-folder-enrichment! *state* (current-second) slate-refresh) (set! *refresh-count* (+ *refresh-count* 1)) (web-client-update "#app" (lambda () (render *state*))) (slate-selector-refresh) ;; "" not #t: the JS boot calls this via eval_string_length, and a ;; non-string result logs a benign runtime "not a string" warning. "") ;; Browser delivery seam for the read-only loopback /node/status endpoint. ;; The view owns no transport and consumes the same schema on desktop and ;; Termux. Invalid or unavailable responses become an honest error state. (define *node-status-transport-url* "unknown") (define *client-update-requested?* #f) (define *performance-capture-request* #f) (define (request-performance-capture action state) (set! *performance-capture-request* action) (with-echo state (if (string=? action "start") "performance capture started" "saving performance capture…"))) (define (slate-performance-capture-take) (let ((request *performance-capture-request*)) (set! *performance-capture-request* #f) (or request ""))) (define (slate-performance-capture-report ok path) (set! *state* (with-echo *state* (if ok (string-append "performance capture saved: " path) (string-append "performance capture save failed" (if (string=? path "") "" (string-append ": " path)))))) (slate-refresh)) (define (slate-performance-trace-set enabled) (enclave-node-performance-trace! enabled) "") (define (slate-performance-trace-poll) (json-encode (enclave-node-performance-trace-poll))) (define (toggle-wake-lock state) (let* ((next (not (dict-ref state slate/keep-screen-awake: #f))) (saved? (save-option! slate/keep-screen-awake: next))) (with-echo (dict-set state slate/keep-screen-awake: next) (string-append "keep screen awake: " (if next "on" "off") (if saved? "" " (preference was not saved)"))))) ;; Browser wake-lock bridge. Strings keep the eval boundary unambiguous. (define (slate-wake-lock-wanted) (if (dict-ref *state* slate/keep-screen-awake: #f) "1" "")) (define (slate-wake-lock-report status message) (when (and (string? status) (not (string=? status "active"))) (set! *state* (with-echo *state* (string-append "keep screen awake: " status (if (and (string? message) (> (string-length message) 0)) (string-append " — " message) "")))) (slate-refresh)) "") (define (request-client-update state) (set! *client-update-requested?* #t) ;; The action consumes old update notices before the persistent inbox is ;; restored by the next client; an activated version must not reappear as ;; though it were another available update. (let ((next (dict-set state slate/notifications: (filter (lambda (n) (let ((source (notification-source n))) (not (and (string? source) (>= (string-length source) 20) (string=? (substring source 0 20) "slate/client-update:"))))) (dict-ref state slate/notifications: '()))))) (notifications-save! next) (with-echo next "updating Slate web client…")))Showing the first 500 of 1051 diff lines for this file. This diff is INCOMPLETE; read the file or clone the repository for the rest.
packages/slate/src/slate/main/preferences.sglmodified
default: #t type: boolean doc: "Focus panes under the pointer" group: appearance) (define-option slate/terminals-use-tmux: default: #t type: boolean doc: "Open terminals through tmux" group: terminal) (define-option slate/terminal-predictive-echo: default: #f type: boolean doc: "Preview simple terminal typing while waiting for remote echo" group: terminal) (define-option slate/keep-screen-awake: default: #f type: boolean doc: "Keep the screen awake while Slate is visible" group: appearance) (define-option slate/opacity: default: 72 type: (integer 10 100) doc: "Content surface opacity" group: appearance) (define-option slate/chrome-opacity:packages/slate/src/slate/modes/folder.sglmodified
;;;;;; A mode over a DIRECTORY source (kind: dir:). It materializes the source's;;; listing through the provider seam and renders it keyboard-first: one entry per;;; line (name, a dir/file rune, a size column), a point/cursor highlight, and a;;; line (name, Git status, size, modification time), a point highlight, and a;;; keymap for fast movement — n/p (or arrows) to move, RET to open, ^ to go to the;;; parent, g to refresh. "Open an entry" is not special-cased: it just builds a;;; new SOURCE (a child dir-source to descend, or a file-source to find-file) and;;; come from (slate providers localstorage) but are pure string ops, not a backend.(define-library (slate modes folder) (import (sigil core) (sigil string) (import (sigil string) (slate folder model) (sigil list) (sigil math) ; quotient (human-size, for files >= 1 KiB) (sigil record) (slate mode) (slate option) (slate root) (slate view) (slate provider) (slate providers localstorage)) folder-reload folder-move folder-point folder-entries folder-count folder-entry-at folder-child-source folder-parent-source folder-set-point folder-set-entries folder-address folder-all-entries folder-set-sort folder-toggle-hidden folder-toggle-preview folder-toggle-compact folder-cache folder-detail folder-detail-set! folder-point-only-change? folder-point-rendered? folder-inherit-settings folder-marks folder-marked? folder-mark folder-unmark folder-toggle-marks folder-clear-marks folder-operation-entries folder-glob-match? folder-mark-matching ;; display helper, exported for its unit tests (dotfiles, .tar.gz, dirs) split-extension) (begin (define-option slate/folder-preview: default: #f type: boolean doc: "Show previews when opening a new folder browser" group: folder) ;; ---- view-state accessors (data under slate/ keys) ---------------------- ;; Entries are stored as a VECTOR: with a 10k-entry capped listing (e.g. (else (list->vector es)))) (define (folder-entries v) (dict-ref v slate/entries: (vector))) (define (folder-all-entries v) (dict-ref v slate/all-entries: (folder-entries v))) (define (folder-cache v) (dict-ref v slate/folder-cache: #f)) (define (folder-detail v key fallback) (let ((cache (folder-cache v))) (if cache (dict-ref (vector-ref cache 0) key fallback) fallback))) (define (folder-detail-set! v key value) (let ((cache (folder-cache v))) (when cache (vector-set! cache 0 (dict-set (vector-ref cache 0) key value))))) (define (folder-point v) (dict-ref v slate/point: 0)) (define (folder-count v) (vector-length (folder-entries v))) ;; Marks are entry NAMES rather than row indices: sorting/reloading may move a ;; Materialize the directory listing through the seam at construction; the ;; listing is a list of entry dicts #{ name: kind: size? } (see fs-read-dir). (define (make-folder-view name source) (folder slate/name: name slate/source: source slate/entries: (->entry-vector (source-materialize source)) slate/point: 0 slate/marks: '())) (let ((v (folder-set-entries (folder slate/name: name slate/source: source slate/entries: (vector) slate/point: 0 slate/marks: '() slate/folder-cache: (vector #{}) slate/folder-sort: name: slate/folder-hidden?: #f slate/folder-preview?: (option-ref slate/folder-preview: #f)) (source-materialize source)))) (folder-detail-set! v listing-ready?: (not (provider-async-op source))) v)) (define (entry-name entry) (dict-ref entry name: "")) (let ((present (entry-name-set entries))) (filter (lambda (mark) (dict-ref present mark #f)) marks))) ;; Re-materialize (g / refresh), clamping point into the new listing. ;; Reproject only when the listing, sorting, or visibility changes. Point ;; and marks follow names; the complete listing remains mutation authority. (define (folder-reproject v) (let* ((at (folder-entry-at v)) (name (and at (entry-name at))) (entries (folder-project-entries (folder-all-entries v) (dict-ref v slate/folder-sort: name:) (dict-ref v slate/folder-hidden?: #f))) (point (let loop ((i 0)) (cond ((>= i (vector-length entries)) (clamp (folder-point v) (vector-length entries))) ((equal? name (entry-name (vector-ref entries i))) i) (else (loop (+ i 1))))))) (dict-set (dict-set v slate/entries: entries) slate/point: point))) (define (folder-set-sort v sort) (folder-reproject (dict-set v slate/folder-sort: sort))) (define (folder-toggle-hidden v) (folder-reproject (dict-set v slate/folder-hidden?: (not (dict-ref v slate/folder-hidden?: #f))))) (define (folder-toggle-preview v) (dict-set v slate/folder-preview?: (not (dict-ref v slate/folder-preview?: #f)))) (define (folder-toggle-compact v) (dict-set v slate/folder-compact?: (not (dict-ref v slate/folder-compact?: #f)))) ;; Navigation carries the user's current toggle; an independently opened ;; browser starts from the declared option instead. (define (folder-inherit-settings from to) (if (and (folder? from) (folder? to)) (dict-set to slate/folder-preview?: (dict-ref from slate/folder-preview?: #f)) to)) (define (folder-point-only-change? before after) (and (folder? before) (folder? after) (= (length (dict-keys before)) (length (dict-keys after))) (list-all? (lambda (key) (or (eq? key slate/point:) (eq? (dict-ref before key #f) (dict-ref after key #f)))) (dict-keys before)))) (define (folder-point-rendered? v) (and (>= (folder-point v) (folder-detail v render-from: 0)) (< (folder-point v) (folder-detail v render-to: 0)))) (define (folder-reload v) (let ((entries (->entry-vector (source-materialize (view-source v))))) (dict-set (dict-set (dict-set v slate/entries: entries) slate/point: (clamp (folder-point v) (vector-length entries))) slate/marks: (marks-in-entries (folder-marks v) entries)))) ;; Install a listing delivered ASYNCHRONOUSLY (an async provider's ;; materialize-async: result, wired in (slate main)) — same clamping as reload, ;; but the entries come from the caller instead of a synchronous materialize. ;; A synchronous provider never needs this (make-folder-view materializes at ;; construction); it exists purely for the async-load path. (folder-set-entries v (source-materialize (view-source v)))) (define (folder-set-entries v entries) (folder-detail-set! v listing-ready?: #t) (folder-detail-set! v load-error: #f) (let ((es (->entry-vector entries))) (dict-set (folder-reproject (dict-set (dict-set v slate/entries: es) slate/point: (clamp (folder-point v) (vector-length es))) slate/marks: (marks-in-entries (folder-marks v) es)))) (dict-set (dict-set v slate/all-entries: es) slate/folder-revision: (+ 1 (dict-ref v slate/folder-revision: 0))) slate/marks: (marks-in-entries (folder-marks v) es))))) (define (folder-move v delta) (dict-set v slate/point: (clamp (+ (folder-point v) delta) (folder-count v)))) (if (null? (folder-marks v)) (let ((entry (folder-entry-at v))) (if entry (list entry) '())) (filter (lambda (entry) (folder-marked? v entry)) (vector->list (folder-entries v))))) (vector->list (folder-all-entries v))))) (define (folder-toggle-marks v) (let ((old (name-set (folder-marks v))) (entries (folder-entries v))) (entries (folder-all-entries v))) (let loop ((i 0) (marks '())) (if (>= i (vector-length entries)) (dict-set v slate/marks: (reverse marks)) (else #f))))) (define (folder-mark-matching v pattern) (let* ((entries (folder-entries v)) (let* ((entries (folder-all-entries v)) (existing (name-set (folder-marks v)))) (let loop ((i 0) (new-marks '())) (if (>= i (vector-length entries)) (cons (substring name 0 i) (substring name i n))) (else (loop (- i 1)))))))) ;; A minimal, Emacs-dired-like row: just the name (dirs get a trailing "/"), ;; with a small right-aligned size for files. No bullets, no chrome — the ;; point line is the only decoration. (define (folder-row entry idx selected? marked?) ;; Rows retain filename keys as metadata arrives. Only the name visits a ;; source; the rest of the row moves point, and the blank gutter toggles marks. (define (folder-row v entry idx selected? marked?) (let* ((name (dict-ref entry name: "")) (dir? (eq? (dict-ref entry kind: file:) dir:)) (parts (split-extension name dir?)) (size (if dir? "" (human-size (dict-ref entry size: 0))))) (bytes (dict-ref entry size: #f)) (badge (dict-ref (folder-detail v git-overlay: #{}) name #{})) (index (number->string idx))) `(div (@ (class ,(string-append "folder-row" (if dir? " is-dir" " is-file") (if marked? " marked" "") (if selected? " selected" ""))) (data-slate-click ,(string-append "folder-open-index:" (number->string idx)))) (span (@ (class "folder-mark")) ,(if marked? "◆" " ")) (span (@ (class "folder-name")) (span (@ (class "folder-stem")) ,(car parts)) ,@(if (equal? (cdr parts) "") '() (list `(span (@ (class "folder-ext")) ,(cdr parts))))) ,@(if (equal? size "") '() (list `(span (@ (class "folder-size")) ,size)))))) ;; The view: just the plain listing. The dired-style path header was removed ;; (slate-ux-sweep #1) — it duplicated the view's mode-line title, so the ;; provider-qualified path now lives only in the mode line / title bar. ;; ;; The render is WINDOWED around the point (±folder-window-half): every row ;; costs ~1ms of interpreted sxml→vdom per frame, and the whole frame ;; re-renders on every point move — an unwindowed 1000-entry dir cost ;; ~0.8-1.3s per open AND per n/p. Rows outside the window collapse into ;; "⋯ N more" indicator lines; the window follows the point, so keyboard ;; motion always has context. (Mouse-wheel browsing past the window edge ;; needs the point to move — the dired-virtualization follow-up lifts this.) (if dir? " is-dir" " is-file") (if marked? " marked" ""))) (key ,name) (role "option") (aria-selected "false") (aria-label ,(string-append name (if marked? ", marked" ""))) (data-slate-click ,(string-append "folder-select-index:" index))) (button (@ (class "folder-mark") (aria-label ,(string-append (if marked? "Unmark " "Mark ") name)) (data-slate-click ,(string-append "folder-mark-index:" index))) ,(if marked? "◆" "")) (button (@ (class "folder-name") (title ,name) (data-slate-click ,(string-append "folder-open-index:" index))) (span (@ (class "folder-stem")) ,(car parts)) (span (@ (class "folder-ext")) ,(cdr parts))) (span (@ (class ,(string-append "folder-git " (dict-ref badge class: ""))) (title ,(dict-ref badge detail: ""))) ,(dict-ref badge label: "")) (span (@ (class "folder-size")) ,(if (and (not dir?) (number? bytes)) (human-size bytes) "—")) (span (@ (class "folder-modified")) ,(folder-modified-text entry (folder-detail v now: #f)))))) ;; Windowing stays bounded; each continuation is a real navigation command. (define folder-window-half 100) (define (more-row n where) `(div (@ (class "folder-more")) ,(string-append "⋯ " (number->string n) " more " (if (eq? where above:) "above" "below")))) ;; The empty state: a quiet centered hint instead of a blank void (polish item ;; 4). Inlined (not (slate components)'s empty-hint) to keep folder's import set ;; minimal on the boot path. (define (folder-empty-hint) (define (more-row n where target) `(button (@ (class "folder-more") (data-slate-click ,(string-append "folder-select-index:" (number->string target)))) ,(string-append (if (eq? where above:) "↑ " "↓ ") (number->string n) " more " (if (eq? where above:) "above" "below") " · show more"))) (define (folder-empty-hint v) `(div (@ (class "empty-hint")) (div (@ (class "empty-hint-glyph")) "▢") (div (@ (class "empty-hint-title")) "This folder is empty") (div (@ (class "empty-hint-sub")) "C-x C-f find a file"))) (define (folder-render v) (div (@ (class "empty-hint-title")) ,(cond ((folder-detail v load-error: #f) "Directory unavailable") ((not (folder-detail v listing-ready?: #f)) "Loading directory…") ((> (vector-length (folder-all-entries v)) 0) "Only hidden entries") (else "This folder is empty"))) ,(if (folder-detail v load-error: #f) `(button (@ (data-slate-click "folder-refresh")) "Retry") `(div (@ (class "empty-hint-sub")) "C-x C-f find a file")))) (define (folder-listing v) (let* ((entries (folder-entries v)) (point (folder-point v)) (n (vector-length entries))) `(div (@ (class "view folder")) ,@(cond ((= n 0) (list (folder-empty-hint))) ((<= n (* 2 folder-window-half)) (map-range (lambda (e i) (folder-row e i (= i point) (folder-marked? v e))) entries 0 n)) (else (let* ((from (max 0 (- point folder-window-half))) (to (min n (+ point folder-window-half 1))) (from (max 0 (min from (- to (* 2 folder-window-half))))) (rows (map-range (lambda (e idx) (folder-row e idx (= idx point) (folder-marked? v e))) entries from to))) (append (if (> from 0) (list (more-row from above:)) '()) rows (if (< to n) (list (more-row (- n to) below:)) '())))))))) (n (vector-length entries)) (to (min n (+ point folder-window-half 1))) (from (max 0 (min (- point folder-window-half) (- to (* 2 folder-window-half)))))) (folder-detail-set! v render-from: from) (folder-detail-set! v render-to: (if (<= n (* 2 folder-window-half)) n to)) (if (= n 0) (list (folder-empty-hint v)) (let ((from (if (<= n (* 2 folder-window-half)) 0 from)) (to (if (<= n (* 2 folder-window-half)) n to))) (append (if (> from 0) (list (more-row from above: (- from 1))) '()) (map-range (lambda (e i) (folder-row v e i (= i point) (folder-marked? v e))) entries from to) (if (< to n) (list (more-row (- n to) below: to)) '())))))) (define (folder-preview-render v) (let* ((entry (folder-entry-at v)) (name (and entry (dict-ref entry name: ""))) (bytes (and entry (dict-ref entry size: #f))) (badge (and name (dict-ref (folder-detail v git-overlay: #{}) name #f)))) `(aside (@ (class ,(string-append "folder-preview" (if (folder-detail v preview-start: #f) " pending" "")))) (div (@ (class "folder-preview-label")) (span "Preview") (button (@ (aria-label "Close preview (P)") (data-slate-click "folder-preview")) "×")) ,@(if (not entry) '((div (@ (class "empty-hint")) "No entry at point")) `((div (@ (class "folder-preview-header")) (button (@ (class "folder-preview-name") (data-slate-click "folder-open")) ,name) (dl (dt "Size") (dd ,(if (number? bytes) (human-size bytes) "—")) (dt "Modified") (dd ,(folder-modified-text entry (folder-detail v now: #f))) ,@(if badge `((dt "Git") (dd ,(dict-ref badge detail: ""))) '()))) ,(let ((url (folder-detail v preview-image-url: #f))) (if url `(div (@ (class "folder-preview-content folder-preview-image")) (img (@ (src ,url) (alt ,name)))) `(pre (@ (class "folder-preview-content")) ,(folder-detail v preview-text: "Waiting…"))))))))) (define (folder-render v) (let* ((source (view-source v)) (address (folder-address v)) (root (source-root source)) (host (if root (or (dict-ref (root-ref root) label: #f) (root-name-string root)) (dict-ref source host: (dict-ref source provider: "")))) (show-git? (folder-detail v git-capable?: #f))) (folder-detail-set! v render-generation: (+ 1 (folder-detail v render-generation: 0))) `(div (@ (class ,(string-append "view folder" (if (dict-ref v slate/folder-preview?: #f) " with-preview" "") (if (dict-ref v slate/folder-compact?: #f) " compact" "") (if show-git? " with-git" ""))) (data-folder-view ,(view-name v)) (data-folder-render ,(number->string (folder-detail v render-generation: 0))) (data-folder-point ,(number->string (folder-point v)))) (header (@ (class "folder-header")) (button (@ (class "folder-parent") (aria-label "Parent directory") (data-slate-click "folder-parent")) "↑") (span (@ (class "folder-path") (title ,address)) (span (@ (class "folder-ancestry")) ,(if (string=? address "/") "" (let ((parent (fs-parent address))) (string-append parent (if (string=? parent "/") "" "/"))))) (strong ,(if (string=? address "/") "/" (fs-basename address)))) (span (@ (class "folder-host")) ,(cond ((string? host) host) ((keyword? host) (keyword->string host)) (else "")))) (div (@ (class "folder-workbench")) (div (@ (class "folder-directory")) (div (@ (class "folder-columns")) (span "") (span "Name") (span (@ (class "folder-git")) "Git") (span "Size") (span (@ (class "folder-modified")) "Modified")) (div (@ (class "folder-listing") (role "listbox") (aria-label "Directory entries")) ,@(folder-listing v))) ,@(if (dict-ref v slate/folder-preview?: #f) (list (folder-preview-render v)) '()))))) ;; ---- the mode: define-mode installs each aspect as a view method --------- ;; keymap is a per-mode LAYER: bare n/p/g/^ bind to folder commands (there is ;; Home/End and the editor's Emacs-style M-</M-> aliases jump to the listing ;; edges, so buffer-extent muscle memory works on both primary view types. keymap: #{ "n" "folder-next" "p" "folder-prev" "P" "folder-preview" "ArrowDown" "folder-next" "ArrowUp" "folder-prev" "PageDown" "folder-page-down" "PageUp" "folder-page-up" "Home" "folder-begin" "End" "folder-end" "^" "folder-parent" "b" "folder-parent" "g" "folder-refresh" "q" "folder-quit" } commands: (lambda (v) (list (make-cand "Folder: Refresh" "g" "folder-refresh") (list (make-cand "Folder: Toggle Preview" "P" "folder-preview") (make-cand "Folder: Hidden" "" "folder-hidden") (make-cand "Folder: Compact Rows" "" "folder-compact") (make-cand "Folder: Sort by Name" "" "folder-sort-name") (make-cand "Folder: Sort by Size" "" "folder-sort-size") (make-cand "Folder: Most Recently Modified" "" "folder-sort-newest") (make-cand "Folder: Least Recently Modified" "" "folder-sort-oldest") (make-cand "Folder: Refresh" "g" "folder-refresh") (make-cand "Folder: Open Parent" "b / ^" "folder-parent") (make-cand "Folder: Mark Entry" "m" "folder-mark") (make-cand "Folder: Unmark Entry" "u" "folder-unmark") actions: (list #{ id: 'folder label: "Folder" glyph: "▤" tier: 'primary items: (list #{ id: 'preview label: "Preview" command: "folder-preview" tier: 'primary } #{ id: 'hidden label: "Hidden" command: "folder-hidden" tier: 'secondary } #{ id: 'sort-name label: "Name ↑" command: "folder-sort-name" tier: 'secondary } #{ id: 'sort-size label: "Size ↓" command: "folder-sort-size" tier: 'secondary } #{ id: 'sort-newest label: "Newest" command: "folder-sort-newest" tier: 'secondary } #{ id: 'sort-oldest label: "Oldest" command: "folder-sort-oldest" tier: 'secondary } #{ id: 'compact label: "Compact" command: "folder-compact" tier: 'secondary } #{ id: 'up label: "↑" key: "ArrowUp" tier: 'primary repeat: #t } #{ id: 'down label: "↓" key: "ArrowDown" tier: 'primary repeat: #t } #{ id: 'open label: "Open" command: "folder-open" tier: 'primary }packages/slate/src/slate/modes/git-diff.sgladded
;;; (slate modes git-diff) — bounded structured diff review surface.(define-library (slate modes git-diff) (import (sigil core) (sigil record) (sigil string) (slate components) (slate components diff) (slate git diff-model) (slate git diff-view) (slate mode) (slate view)) (export git-diff? git-diff-model git-diff-rows git-diff-selected-row git-diff-move git-diff-render git-diff-view-name) (begin (define (icon-button icon label command) `(button (@ (class "git-header-icon") (aria-label ,label) (title ,label) (data-slate-click ,command)) (span (@ (class ,(string-append "icon i-" icon)))))) (define (line-class kind selected?) (string-append "git-diff-row git-diff-" (if (keyword? kind) (keyword->string kind) "meta") (if selected? " selected" ""))) (define (git-diff-render v) (let* ((model (git-diff-model v)) (state (if model (git-diff-model-state model) #{ state: loading: })) (kind (dict-ref state state: loading:)) (commit (dict-ref v slate/git-commit: #f)) (selected (dict-ref v slate/git-selected: #f))) (let ((header `(header (@ (class "git-buffer-header")) (div (@ (class "git-header-line")) (span (@ (class "git-header-label")) "Diff") (span (@ (class "git-header-value")) ,(if (eq? kind loading:) "Loading…" (if (eq? kind error:) "Diff failed or became stale." (string-append (git-diff-model-kind model) " " (or (git-diff-model-target model) "all staged changes")))))) ,@(if commit `((div (@ (class "git-header-line")) (span (@ (class "git-header-label")) "Subject") (span (@ (class "git-header-value")) ,(dict-ref commit subject: ""))) (div (@ (class "git-header-line")) (span (@ (class "git-header-label")) "Commit") (span (@ (class "git-header-value")) ,(dict-ref commit oid: ""))) (div (@ (class "git-header-line")) (span (@ (class "git-header-label")) "Parents") (span (@ (class "git-header-value")) ,(string-join (let ((parents (dict-ref commit parents: '()))) (if (array? parents) (array->list parents) parents)) " ")))) '()) (div (@ (class "git-header-actions")) ,(icon-button "git-branch" "Branches" "git-branch-menu") ,(icon-button "history" "Refresh diff" "git-diff-refresh") ,(icon-button "x" "Close diff" "git-quit")))) (control (diff-control-render (git-diff-rows v) selected (lambda (row) (let ((id (dict-ref row id: ""))) (if (eq? (dict-ref row kind: #f) continuation:) "git-diff-more" (string-append "git-diff-select:" id))))))) `(div (@ (class "view git-diff git-buffer")) ,header ,control)))) (define-mode git-diff-mode glyph: "Δ" icon: "diff" lighter: "Diff" keymap: #{ "b" "git-branch-menu" "n" "git-next" "ArrowDown" "git-next" "p" "git-prev" "ArrowUp" "git-prev" "Enter" "git-visit" "g" "git-diff-refresh" "l" "git-log" "?" "git-help" "q" "git-quit" } commands: (lambda (v) (list (make-cand "Git: Branches" "b" "git-branch-menu") (make-cand "Diff: Visit line" "RET" "git-visit") (make-cand "Diff: Load more" "" "git-diff-more") (make-cand "Diff: Refresh" "g" "git-diff-refresh") (make-cand "Diff: Close" "q" "git-quit"))) actions: (list #{ id: 'git-diff label: "Diff" glyph: "Δ" tier: 'primary items: (list #{ id: 'up label: "↑" key: "ArrowUp" tier: 'primary repeat: #t } #{ id: 'down label: "↓" key: "ArrowDown" tier: 'primary repeat: #t } #{ id: 'visit label: "Open" command: "git-visit" tier: 'primary } #{ id: 'more label: "Load more" command: "git-diff-more" tier: 'secondary } #{ id: 'quit label: "Close" command: "git-quit" tier: 'tertiary }) }) render: git-diff-render) (define (git-diff? v) (git-diff-mode? v))))packages/slate/src/slate/modes/git-log.sgladded
;;; (slate modes git-log) — keyboard-first history with lazy commit review.(define-library (slate modes git-log) (import (sigil record) (sigil math) (sigil string) (sigil web client) (slate components) (slate components diff) (slate mode) (slate option) (slate view) (slate modes markdown-view) (slate git log-model) (slate git log-view) (slate git diff-model) (slate git diff-view)) (export git-log-render) (begin (define (button label command) `(button (@ (data-slate-click ,command)) ,label)) (define (hint key label command) `(button (@ (data-slate-click ,command)) (kbd ,key) ,label)) (define (x lane) (number->string (+ 9 (* lane 14)))) (define (graph row) ;; The current DOM bridge creates HTML elements only. Use a themed SVG ;; mask on an ordinary span, as Slate's existing icon system does. (let* ((lane (dict-ref row lane: 0)) (width (+ 4 (* 14 (max 1 (dict-ref row before: 1) (dict-ref row after: 1))))) (drawing (string-append "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 " (number->string width) " 32'>" "<g fill='none' stroke='black' stroke-width='1.3'>" (string-join (map (lambda (edge) (string-append "<path d='M " (x (car edge)) " " (if (= (car edge) lane) "16" "0") " L " (x (car edge)) " 16 L " (x (cdr edge)) " 32'/>")) (dict-ref row edges: '())) "") "<path d='M " (x lane) " 0 V 16'/></g>" "<circle cx='" (x lane) "' cy='16' r='3.5'/></svg>")) (encoded (string-replace (string-replace (string-replace drawing "<" "%3C") ">" "%3E") " " "%20"))) `(span (@ (class "git-log-graph") (aria-hidden "true") (style ,(string-append "width:" (number->string width) "px;mask-image:url(\"data:image/svg+xml," encoded "\")")))))) (define (commit-row row selected) (let* ((commit (dict-ref row commit: #{})) (oid (dict-ref commit oid: "")) (refs (dict-ref commit refs: ""))) `(button (@ (class ,(string-append "git-row git-log-row" (if (equal? oid selected) " selected" ""))) (key ,oid) (data-oid ,oid) (aria-pressed ,(if (equal? oid selected) "true" "false")) (data-slate-click ,(string-append "git-log-open:" oid))) ,(graph row) (span (@ (class "git-commit-oid")) ,(substring oid 0 7)) (span (@ (class "git-log-summary")) ,@(if (string=? refs "") '() `((span (@ (class "git-log-refs")) ,refs))) (span (@ (class "git-log-subject")) ,(dict-ref commit subject: ""))) (span (@ (class "git-log-author")) ,(dict-ref commit author: ""))))) (define (review-content review) (cond ((not review) `(div (@ (class "git-log-empty")) (span (@ (class "icon i-git-commit-horizontal"))) (strong "Explore this history") (span "Select a commit, then press Enter to inspect it.") ,(hint "RET" "Inspect commit" "git-log-inspect"))) ((eq? (dict-ref review state: #f) loading:) `(div (@ (class "git-log-empty") (role "status")) "Loading commit…")) ((eq? (dict-ref review state: #f) error:) `(div (@ (class "git-log-empty") (role "alert")) "Commit could not be loaded." ,(button "Retry selected commit" "git-log-inspect"))) (else (let* ((commit (dict-ref review commit: #{})) (diff (dict-ref review diff: #f)) (state (git-diff-model-state diff)) (parents-value (dict-ref commit parents: '())) (parents (if (array? parents-value) (array->list parents-value) parents-value))) `(div (@ (class "git-log-inspection")) (header (@ (class "git-log-commit-header")) (div (@ (class "git-eyebrow")) "COMMIT") (h2 ,(dict-ref commit subject: "")) (div (@ (class "git-log-metadata")) (span ,(dict-ref commit author: "")) (span ,(dict-ref commit date: ""))) (div (@ (class "git-log-oid")) ,(dict-ref commit oid: "")) ,(button "Copy commit ID" "git-log-copy-review") ,@(if (pair? parents) `((div (@ (class "git-log-parents")) (span "Parents ") ,@(map (lambda (oid) (button (substring oid 0 7) (string-append "git-log-inspect:" oid))) parents))) '()) (div (@ (class "git-log-message")) ,(markdown-render-body-sxml (dict-ref commit body: ""))) ,@(if (> (length parents) 1) `((p (@ (class "git-log-note")) "Merge changes compared with the first parent.")) '())) ,@(cond ((eq? (dict-ref state state: #f) loading:) `((p (@ (role "status")) "Loading changes…"))) ((eq? (dict-ref state state: #f) error:) `((p (@ (role "alert")) "Changes could not be loaded. Open the commit again to retry."))) (else '())) ,(diff-control-render (git-diff-rows #{ slate/git-diff-model: diff }) #f (lambda (row) (and (eq? (dict-ref row kind: #f) continuation:) "git-log-diff-more")))))))) (define (git-log-render v) (let* ((model (git-log-model v)) (state (git-log-model-state model)) (commits (git-log-commits model)) (selected (git-log-selected v)) (repo (git-log-model-repo model)) (kind (dict-ref state state: idle:)) (offset (dict-ref state offset: 0)) (review-focused? (eq? (dict-ref v slate/git-log-focus: 'list) 'review)) (layout (view-option v slate/git-layout: 'auto))) `(div (@ (class "view git-log git-buffer") (data-layout ,(symbol->string layout)) (data-focus ,(if review-focused? "review" "list")) (data-review-oid ,(dict-ref (or (dict-ref state review: #f) #{}) oid: "")) (data-scroll-revision ,(number->string (dict-ref v slate/git-log-scroll-revision: 0))) (data-scroll ,(dict-ref v slate/git-log-scroll: ""))) (header (@ (class "git-log-header")) (div (div (@ (class "git-eyebrow")) "HISTORY") (h1 ,(dict-ref (dict-ref state branch: #{}) head: "Repository")) (div (@ (class "git-log-path")) ,(dict-ref repo worktree: ""))) (div (@ (class "git-header-actions")) ,(button "Refresh" "git-log-refresh") ,(button "Close" "git-log-quit")) (div (@ (class "git-layout-control") (role "group") (aria-label "History layout")) ,@(map (lambda (pair) `(button (@ (data-slate-click ,(string-append "git-layout:" (car pair))) (aria-pressed ,(if (string=? (symbol->string layout) (car pair)) "true" "false"))) ,(cdr pair))) '(("auto" . "Auto") ("inline" . "Stacked") ("split" . "Split"))))) (div (@ (class "git-log-workbench")) (section (@ (class "git-log-list") (aria-label "Commits")) (div (@ (class "git-log-pane-label")) ,(hint "ESC" "Commits" "git-log-focus-list") (span ,(if review-focused? "" "n / p move"))) (div (@ (class "git-log-pagination")) (span ,(cond ((eq? kind loading:) "Loading history…") ((eq? kind error:) "History unavailable or changed. Refresh to retry.") ((null? commits) "No commits yet") (else (string-append (number->string (+ offset 1)) "–" (number->string (+ offset (length commits))) " · current branch")))) ,@(if (> offset 0) (list (button "Newer" "git-log-newer")) '()) ,@(if (dict-ref state next-cursor: #f) (list (button "Older" "git-log-older")) '())) ,@(map (lambda (row) (commit-row row (and selected (dict-ref selected oid: #f)))) (git-log-graph commits))) (section (@ (class "git-log-review") (aria-label "Commit review")) (div (@ (class "git-log-pane-label")) ,(hint "TAB" "Review" "git-log-switch-pane") (span ,(if review-focused? "n / p scroll · PgUp / PgDn" "Tab to enter"))) (div (@ (class "git-log-review-body") (tabindex "-1")) ,(web-client-memo "history-review" (number->string (git-log-model-revision model)) (lambda () (review-content (dict-ref state review: #f))))))) (footer (@ (class "git-command-strip")) ,(hint "n / p" (if review-focused? "Scroll" "Move") "git-log-next") ,(hint "TAB" (if review-focused? "Back to commits" "Enter review") "git-log-switch-pane") ,(hint "w" "Copy ID" "git-log-copy") ,(hint "b" "Branches" "git-branch-menu") ,(hint "g" "Refresh" "git-log-refresh") ,(hint "?" "Commands" "git-help"))))) (define-mode git-log-mode glyph: "◉" icon: "history" lighter: "History" info: (lambda (v) (string-append "History · " (number->string (length (git-log-commits (git-log-model v)))) " commits")) keymap: #{ "b" "git-branch-menu" "n" "git-log-next" "ArrowDown" "git-log-next" "p" "git-log-prev" "ArrowUp" "git-log-prev" "Enter" "git-log-inspect" "Tab" "git-log-switch-pane" "Escape" "git-log-focus-list" "PageDown" "git-log-page-down" "PageUp" "git-log-page-up" "Home" "git-log-top" "End" "git-log-bottom" "g" "git-log-refresh" "w" "git-log-copy" "]" "git-log-older" "[" "git-log-newer" "v" "git-layout" "?" "git-help" "q" "git-log-quit" } commands: (lambda (v) (list (make-cand "Git: Branches" "b" "git-branch-menu") (make-cand "History: Switch pane" "TAB" "git-log-switch-pane") (make-cand "History: Back to commits" "ESC" "git-log-focus-list") (make-cand "History: Scroll review down" "PgDn" "git-log-page-down") (make-cand "History: Scroll review up" "PgUp" "git-log-page-up") (make-cand "History: Inspect commit" "RET" "git-log-inspect") (make-cand "History: Copy commit ID" "w" "git-log-copy") (make-cand "History: Older commits" "]" "git-log-older") (make-cand "History: Newer commits" "[" "git-log-newer") (make-cand "History: Refresh" "g" "git-log-refresh") (make-cand "History: Close" "q" "git-log-quit"))) actions: (list #{ id: 'history label: "History" glyph: "◉" tier: 'primary items: (list #{ id: 'up label: "↑" key: "ArrowUp" tier: 'primary repeat: #t } #{ id: 'down label: "↓" key: "ArrowDown" tier: 'primary repeat: #t } #{ id: 'inspect label: "Inspect" command: "git-log-inspect" tier: 'primary } #{ id: 'more label: "Older" command: "git-log-older" tier: 'secondary } #{ id: 'quit label: "Close" command: "git-log-quit" tier: 'tertiary }) }) render: git-log-render)))packages/slate/src/slate/modes/git-status.sgladded
;;; (slate modes git-status) — structured, read-only Git status presentation.(define-library (slate modes git-status) (import (sigil core) (sigil math) (sigil record) (sigil string) (slate components) (slate components diff) (slate git model) (slate git status-view) (slate mode) (slate option) (slate view)) (export git-status? git-status-model git-status-rows git-status-move git-status-toggle git-status-selected-row git-status-render git-status-view-name git-status-repository-view-name) (begin (define-option slate/git-layout: default: auto type: (enum auto inline split) doc: "Git review layout: automatic by pane width, inline, or split" group: git) (define (model-state v) (let ((model (git-status-model v))) (if model (git-status-model-state model) #{ state: loading: }))) (define (status-data v) (dict-ref (model-state v) data: #{})) (define (value-text value) (cond ((not value) "—") ((string? value) value) ((keyword? value) (keyword->string value)) (else (call-with-output-string (lambda (p) (write value p)))))) (define (entry-label entry) (or (dict-ref entry path: #f) (dict-ref entry original-path: #f) "(unknown path)")) (define (as-list value) (cond ((array? value) (array->list value)) ((list? value) value) (else '()))) (define (short-oid oid) (if (not (string? oid)) "—" (substring oid 0 (min 7 (string-length oid))))) (define (count-if pred values) (length (filter pred values))) (define (git-status-info v) (let* ((state (model-state v)) (kind (dict-ref state state: loading:)) (data (dict-ref state data: #{})) (entries (as-list (dict-ref data entries: '()))) (staged (count-if (lambda (entry) (let ((kind (dict-ref entry kind: "ordinary")) (index (dict-ref entry index-state: ".")) (worktree (dict-ref entry worktree-state: "."))) (and (not (string=? kind "conflict")) (not (string=? kind "untracked")) (not (string=? index "U")) (not (string=? worktree "U")) (not (string=? index "."))))) entries)) (untracked (count-if (lambda (entry) (string=? (dict-ref entry kind: "") "untracked")) entries)) (counts (string-append (number->string (length entries)) " changed · " (number->string staged) " staged · " (number->string untracked) " untracked"))) (cond ((eq? kind loading:) "Git · loading…") ((and (eq? kind stale:) (not (dict-ref state external-change?: #f))) (string-append "Git · refreshing… · " counts)) ((eq? kind stale:) (string-append "Git · refresh needed · " counts)) ((eq? kind error:) (string-append "Git · refresh failed · " counts)) ((eq? (dict-ref state mutation: #f) pending:) (string-append "Git · updating… · " counts)) (else (string-append "Git · " counts))))) (define (icon-button icon label command class) `(button (@ (class ,class) (aria-label ,label) (title ,label) (data-slate-click ,command)) (span (@ (class ,(string-append "icon i-" icon)))))) (define (render-row row selected? folded) (let ((kind (dict-ref row kind: #f)) (id (dict-ref row id: ""))) (cond ((eq? kind section:) (let ((section (dict-ref row section: #f))) `(div (@ (class ,(if selected? "git-row git-section selected" "git-row git-section")) (key ,id) (data-slate-click ,(string-append "git-select-toggle:" id)) (aria-expanded ,(if (member id folded) "false" "true"))) (button (@ (class "git-section-toggle") (data-slate-click ,(string-append "git-select-toggle:" id)) (aria-expanded ,(if (member id folded) "false" "true"))) (span (@ (class "git-chevron") (aria-hidden "true")) ,(if (member id folded) "▸" "▾")) ,(dict-ref row label: "") (span (@ (class "git-section-count")) ,(number->string (dict-ref row count: 0))) (span (@ (class "git-section-rule") (aria-hidden "true")))) ,@(cond ((or (eq? section unstaged:) (eq? section untracked:)) (list `(button (@ (class "git-entry-mutation") (data-slate-click ,(string-append "git-select-stage:" id))) "Stage all"))) ((eq? section staged:) (list `(button (@ (class "git-entry-mutation") (data-slate-click ,(string-append "git-select-unstage:" id))) "Unstage all"))) (else '()))))) ((eq? kind entry:) (let ((entry (dict-ref row entry: #{}))) `(div (@ (class ,(string-append "git-row git-entry git-" (keyword->string (dict-ref row section: unstaged:)) (if selected? " selected" ""))) (key ,id) (data-slate-click ,(string-append "git-select:" id))) (span (@ (class "git-entry-state")) ,(string-append (dict-ref entry index-state: ".") (dict-ref entry worktree-state: "."))) (button (@ (class "git-entry-toggle") (aria-label ,(string-append "Toggle diff " (entry-label entry))) (aria-expanded ,(if (dict-ref row expanded: #f) "true" "false")) (data-slate-click ,(string-append "git-select-toggle:" id))) ,(if (dict-ref row expanded: #f) "▾" "▸")) (button (@ (class "git-entry-path") (title ,(entry-label entry)) (data-slate-click ,(string-append "git-visit:" id))) ,@(path-label (entry-label entry))) ,(icon-button "code" (string-append "Diff " (entry-label entry)) (string-append "git-select-diff:" id) "git-entry-diff") ,@(if (eq? (dict-ref row section: #f) conflicts:) (let ((stages (dict-ref entry conflict-stages: #{}))) (list `(span (@ (class "git-conflict-stages")) ,(string-append "base " (value-text (dict-ref stages stage-1: #f)) " ours " (value-text (dict-ref stages stage-2: #f)) " theirs " (value-text (dict-ref stages stage-3: #f)))) `(span (@ (class "git-conflict-actions")) (button (@ (class "git-conflict-stage") (data-slate-click ,(string-append "git-conflict-base:" id))) "Base") (button (@ (class "git-conflict-stage") (data-slate-click ,(string-append "git-conflict-ours:" id))) "Ours") (button (@ (class "git-conflict-stage") (data-slate-click ,(string-append "git-conflict-theirs:" id))) "Theirs")))) '()) ,@(cond ((or (eq? (dict-ref row section: #f) unstaged:) (eq? (dict-ref row section: #f) untracked:)) (list (icon-button "plus" (string-append "Stage " (entry-label entry)) (string-append "git-select-stage:" id) "git-entry-mutation") (icon-button "trash-2" (string-append "Discard " (entry-label entry) " permanently") (string-append "git-select-discard:" id) "git-entry-discard"))) ((eq? (dict-ref row section: #f) staged:) (list (icon-button "corner-down-left" (string-append "Unstage " (entry-label entry)) (string-append "git-select-unstage:" id) "git-entry-mutation"))) (else '()))))) ((dict-ref row owner: #f) (diff-row-render (with-hunk-mutation row) selected? (cond ((diff-kind=? (diff-row-kind row) hunk:) (string-append "git-select-toggle:" id)) ((diff-kind=? (diff-row-kind row) continuation:) (string-append "git-inline-more:" (dict-ref row owner: ""))) (else (string-append "git-select:" id))))) (else (let ((commit (dict-ref row commit: #{}))) `(button (@ (class ,(if selected? "git-row git-commit selected" "git-row git-commit")) (key ,id) (data-slate-click ,(string-append "git-select:" id))) (span (@ (class "git-commit-oid")) ,(short-oid (dict-ref commit oid: #f))) (span (@ (class "git-commit-subject")) ,(dict-ref commit subject: "(no subject)")))))))) (define (inline-command row) (let ((id (dict-ref row id: ""))) (cond ((diff-kind=? (diff-row-kind row) hunk:) (string-append "git-select-toggle:" id)) ((diff-kind=? (diff-row-kind row) continuation:) (string-append "git-inline-more:" (dict-ref row owner: ""))) (else (string-append "git-select:" id))))) (define (with-hunk-mutation row) (if (not (dict-ref row hunk-id: #f)) row (let* ((id (dict-ref row id: "")) (side (dict-ref (dict-ref row hunk-id: #{}) side: "")) (staged? (string=? side "staged")) (next (dict-set row mutation-command: (string-append (if staged? "git-select-unstage:" "git-select-stage:") id))) ;; Labels are host policy; opaque row IDs can contain action ;; names in a directory, filename, or section suffix. (next (dict-set next mutation-label: (if staged? "Unstage hunk" "Stage hunk"))) (next (dict-set next mutation-icon: (if staged? "corner-down-left" "plus")))) (if (string=? side "worktree") (dict-set next discard-command: (string-append "git-select-discard:" id)) next)))) (define (path-label path) (let loop ((i (- (string-length path) 1))) (cond ((< i 0) (list path)) ((char=? (string-ref path i) #\/) (list `(span (@ (class "git-path-directory")) ,(substring path 0 (+ i 1))) (substring path (+ i 1) (string-length path)))) (else (loop (- i 1)))))) (define (path-name path) (let ((parts (string-split path "/"))) (if (null? parts) path (list-ref parts (- (length parts) 1))))) (define (row-for rows id) (cond ((or (not id) (null? rows)) #f) ((equal? id (dict-ref (car rows) id: #f)) (car rows)) (else (row-for (cdr rows) id)))) (define (review-title row) (let ((entry (dict-ref row entry: #{}))) `(div (@ (class "git-review-title")) (span (@ (class "git-review-path") (title ,(entry-label entry))) ,(entry-label entry)) (span (@ (class "git-review-side")) ,(if (eq? (dict-ref row section: #f) staged:) "HEAD → index" "index → worktree"))))) (define (render-status-rows rows selected folded active all-rows revision) (if (null? rows) '() (let ((owner (dict-ref (car rows) owner: #f))) (if (not owner) (cons (render-row (car rows) (and selected (string=? selected (dict-ref (car rows) id: ""))) folded) (render-status-rows (cdr rows) selected folded active all-rows revision)) (let collect ((rest rows) (group '())) (if (and (not (null? rest)) (equal? owner (dict-ref (car rest) owner: #f))) (collect (cdr rest) (cons (car rest) group)) (cons `(div (@ (class ,(string-append "git-inline-diff" (if (equal? owner active) " active" ""))) (key ,(string-append "review:" owner)) (data-review-owner ,owner)) ,(review-title (or (row-for all-rows owner) #{})) ,@(let ((state (dict-ref (car group) review-state: #f))) (cond ((or (eq? state deferred:) (eq? state error:)) (list `(button (@ (class "git-review-refresh") (data-slate-click ,(string-append "git-inline-refresh:" owner))) "Refresh this review"))) ((eq? state stale:) (list `(div (@ (class "git-review-refresh") (role "status")) "Updating review…"))) (else '()))) ,(diff-control-render (reverse group) selected inline-command revision: revision prepare-row: with-hunk-mutation)) (render-status-rows rest selected folded active all-rows revision)))))))) (define (layout-control v) (let ((layout (view-option v slate/git-layout: 'auto))) `(div (@ (class "git-layout-control") (role "group") (aria-label "Git review layout")) ,@(map (lambda (choice) `(button (@ (data-slate-click ,(string-append "git-layout:" (car choice))) (aria-pressed ,(if (eq? layout (string->symbol (car choice))) "true" "false")) (title ,(cdr choice))) ,(cond ((string=? (car choice) "auto") "Auto") ((string=? (car choice) "inline") "Inline") (else "Split")))) '(("auto" . "Follow the Git pane width") ("inline" . "Review diffs in the status outline") ("split" . "Review beside the status outline")))))) (define (command-hint key label command) `(button (@ (data-slate-click ,command)) (kbd ,key) ,label)) (define (command-strip row) (let* ((kind (and row (dict-ref row kind: #f))) (hunk (and row (dict-ref row hunk-id: #f))) (section (and row (dict-ref row section: #f))) (staged? (or (eq? section staged:) (and hunk (string=? (dict-ref hunk side: "") "staged")))) (work? (or (eq? section unstaged:) (eq? section untracked:) (and hunk (string=? (dict-ref hunk side: "") "worktree"))))) `(nav (@ (class "git-command-strip") (aria-label "Git commands")) ,(command-hint "n / p" "Move" "git-next") ,(command-hint "Tab" "Fold" "git-toggle") ,(command-hint "↵" "Open" "git-visit") ,@(cond (staged? (list (command-hint "u" "Unstage" "git-unstage"))) (work? (list (command-hint "s" "Stage" "git-stage"))) (else '())) ,@(if (and work? (not (eq? kind section:))) (list (command-hint "k" "Discard" "git-discard")) '()) ,(command-hint "g" "Refresh" "git-refresh") ,(command-hint "?" "Commands" "git-help") (span (@ (class "git-selection-scope")) ,(cond (hunk "Hunk selected") ((eq? kind entry:) "File selected") ((eq? kind section:) "Section selected") ((eq? kind commit:) "Commit selected") (else "")))))) (define (render-ready v data) (let* ((repo (dict-ref data repo: #{})) (branch (dict-ref data branch: #{})) (selected (dict-ref v slate/git-selected: #f)) (folded (dict-ref v slate/git-folded: '())) (rows (git-status-rows v)) (row (row-for rows selected)) (owner (and row (or (dict-ref row owner: #f) (and (eq? (dict-ref row kind: #f) entry:) selected)))) (entry-row (row-for rows owner)) (open? (and entry-row (dict-ref entry-row expanded: #f))) (source (view-source v)) (commits (as-list (dict-ref data recent-commits: '()))) (latest (and (pair? commits) (car commits))) (operation (dict-ref data operation: #f)) (path (dict-ref repo worktree: ""))) `(div (@ (class "view git-status git-buffer") (data-review-version ,(number->string (dict-ref v slate/git-review-version: 0))) (data-layout ,(symbol->string (view-option v slate/git-layout: 'auto)))) (header (@ (class "git-repository-header")) (div (@ (class "git-repository-title")) (span (@ (class "git-repository-name") (title ,path) (aria-label "Repository")) ,(path-name path)) (span (@ (class "git-repository-origin") (aria-label "Root / node")) ,(string-append (value-text (or (dict-ref source host: #f) (dict-ref source provider: #f))) " / " (value-text (dict-ref repo root: #f)))) ,(icon-button "git-branch" "Branches (b)" "git-branch-menu" "git-header-icon") ,(icon-button "history" "History (l)" "git-log" "git-header-icon") ,(icon-button "history" "Refresh Git status" "git-refresh" "git-header-icon") ,(icon-button "x" "Close Git status" "git-quit" "git-header-icon")) (div (@ (class "git-branch-line")) (span (@ (class "icon i-git-branch") (aria-hidden "true"))) (span (@ (class "git-branch-name")) ,(value-text (dict-ref branch head: (dict-ref branch oid: #f)))) ,@(if (dict-ref branch upstream: #f) `((span (@ (class "git-upstream")) "→ " ,(dict-ref branch upstream: ""))) '()) (span (@ (class "git-ahead")) "↑ " ,(number->string (dict-ref branch ahead: 0))) (span (@ (class "git-behind")) "↓ " ,(number->string (dict-ref branch behind: 0)))) (div (@ (class "git-repository-detail")) (span (@ (class "git-repository-path") (title ,path)) ,path) ,@(if latest `((span (@ (class "git-latest-commit") (aria-label "Latest commit")) (span (@ (class "git-commit-oid")) ,(short-oid (dict-ref latest oid: #f))) " " ,(dict-ref latest subject: "(no subject)"))) '())) ,@(if operation `((div (@ (class "git-operation") (role "status")) "Operation in progress · " ,(value-text operation))) '()) (div (@ (class "git-review-toolbar")) (span (@ (class "git-review-label")) "Changes") ,(layout-control v))) (div (@ (class "git-workbench")) (div (@ (class "git-outline") (aria-label "Git status outline")) ,@(if (null? (as-list (dict-ref data entries: '()))) `((div (@ (class "git-clean-state")) (strong "Working tree clean") (span "No uncommitted changes."))) '()) ,@(render-status-rows rows selected folded owner rows (git-status-row-revision v))) ,@(if open? '() (list `(aside (@ (class "git-review-empty") (aria-label "Selected file review")) ,@(if entry-row (list (review-title entry-row)) '()) (div (@ (class "git-review-prompt")) (span (@ (class "icon i-code") (aria-hidden "true"))) (strong ,(if entry-row "Ready to review" "Select a change")) (p ,(if entry-row "Expand this file to review its diff." "Use n and p to move through files and hunks.")) ,@(if entry-row (list (command-hint "Tab" "Open diff" "git-toggle")) '())))))) ,(command-strip row)))) (define (git-status-render v) (let* ((state (model-state v)) (kind (dict-ref state state: loading:)) (data (dict-ref state data: #f))) (cond ((eq? kind loading:) `(div (@ (class "view git-status loading")) ,(card "Git status" "Loading…"))) ((eq? kind unavailable:) `(div (@ (class "view git-status unavailable")) ,(card "Git unavailable" "This root has no node-backed Git service."))) ((and (or (eq? kind stale:) (eq? kind error:)) data) (render-ready v data)) ((eq? kind error:) `(div (@ (class "view git-status error")) ,(card "Git status failed" "The node could not load this repository."))) (else (render-ready v data))))) (define-mode git-status-mode glyph: "±" icon: "source-control" lighter: "Git" info: git-status-info keymap: #{ "b" "git-branch-menu" "n" "git-next" "ArrowDown" "git-next" "p" "git-prev" "ArrowUp" "git-prev" "Tab" "git-toggle" "Enter" "git-visit" "s" "git-stage" "u" "git-unstage" "k" "git-discard" "l" "git-log" "g" "git-refresh" "d" "git-diff" "v" "git-layout" "?" "git-help" "q" "git-quit" } commands: (lambda (v) (list (make-cand "Git: Branches" "b" "git-branch-menu") (make-cand "Git: Cycle layout" "v" "git-layout") (make-cand "Git: Refresh" "g" "git-refresh") (make-cand "Git: Toggle" "TAB" "git-toggle") (make-cand "Git: Visit" "RET" "git-visit") (make-cand "Git: History" "l" "git-log") (make-cand "Git: Diff" "d" "git-diff") (make-cand "Git: Stage" "s" "git-stage") (make-cand "Git: Unstage" "u" "git-unstage") (make-cand "Git: Discard" "k" "git-discard") (make-cand "Git: Close" "q" "git-quit"))) actions: (list #{ id: 'git label: "Git" glyph: "±" tier: 'primary items: (list #{ id: 'up label: "↑" key: "ArrowUp" tier: 'primary repeat: #t } #{ id: 'down label: "↓" key: "ArrowDown" tier: 'primary repeat: #t } #{ id: 'toggle label: "Fold" command: "git-toggle" tier: 'primary } #{ id: 'visit label: "Open" command: "git-visit" tier: 'primary } #{ id: 'branches label: "Branches" command: "git-branch-menu" tier: 'primary } #{ id: 'history label: "History" command: "git-log" tier: 'primary } #{ id: 'diff label: "Diff" command: "git-diff" tier: 'primary } #{ id: 'stage label: "Stage" command: "git-stage" tier: 'primary } #{ id: 'unstage label: "Unstage" command: "git-unstage" tier: 'primary } #{ id: 'discard label: "Discard" command: "git-discard" tier: 'primary } #{ id: 'refresh label: "Refresh" command: "git-refresh" tier: 'secondary } #{ id: 'quit label: "Close" command: "git-quit" tier: 'tertiary }) }) render: git-status-render) (define (git-status? v) (git-status-mode? v))))packages/slate/src/slate/modes/node-status.sgladded
;;; (slate modes node-status) — local node lifecycle facts from /node/status.(define-library (slate modes node-status) (import (sigil core) (sigil record) (slate mode) (slate components) (slate view)) (export make-node-status-view node-status? node-status-view-name node-status-loading node-status-ready node-status-failed node-status-connection) (begin (define node-status-view-name "slate:/node-status") (define (text value fallback) (cond ((string? value) value) ((number? value) (number->string value)) ((eq? value #t) "yes") ((eq? value #f) "no") (else fallback))) (define (field record key fallback) (text (and (dict? record) (dict-ref record key #f)) fallback)) (define (status-summary status) (let* ((listener (dict-ref status listener: #{})) (process (dict-ref status process: #{})) (healthy? (dict-ref listener healthy: #f)) (live? (dict-ref process live: #f))) (cond (healthy? (cons "Reachable" 'ok)) (live? (cons "Process live; listener unavailable" 'warn)) (else (cons "Unavailable" 'err))))) (define (capability-state capability) (if (dict-ref capability available: #f) "available" "unavailable")) (define (node-status-render v) (let ((phase (dict-ref v slate/node-status-phase: loading:)) (status (dict-ref v slate/node-status: #{})) (error-text (dict-ref v slate/node-status-error: "")) (connected? (dict-ref v slate/node-connected?: #f)) (transport-url (dict-ref v slate/node-transport-url: "unknown"))) `(div (@ (class "view welcome node-status-view") (data-node-status-phase ,(keyword->string phase))) (div (@ (class "welcome-page")) ,(section "Local node" `(p (@ (class "welcome-tagline")) "Lifecycle and listener facts for the node serving this Slate app.") (button "Refresh" "node-status-refresh" 'ghost)) ,(cond ((eq? phase loading:) (card "Checking" `(p "Reading the loopback node status…"))) ((eq? phase error:) (card "Unavailable" `(p ,error-text) `(p (@ (class "welcome-note")) "This is expected when Slate is not being served by a local node."))) (else (let* ((platform (dict-ref status platform: #{})) (identity (dict-ref status identity: #{})) (posture (dict-ref status capability-posture: #{})) (resource (dict-ref posture resource: #{})) (session (dict-ref posture session: #{})) (agent (dict-ref posture agent: #{})) (renderer (dict-ref posture renderer: #{})) (remote-manager (dict-ref posture remote-transport-manager: #{})) (upgrade (dict-ref status upgrade: #{})) (upgrade-check (dict-ref upgrade check: #{})) (upgrade-apply (dict-ref upgrade apply: #{})) (upgrade-rollback (dict-ref upgrade rollback: #{})) (upgrade-installation (dict-ref upgrade installation: #{})) (installation (dict-ref status installation: #{})) (supervision (dict-ref status supervision: #{})) (process (dict-ref status process: #{})) (listener (dict-ref status listener: #{})) (lifecycle (dict-ref status lifecycle: #{})) (autostart (dict-ref status autostart: #{})) (app (dict-ref status app: #{})) (client (dict-ref status web-client: #{})) (summary (status-summary status))) (cols (card "Current connection" (h-stack (icon-dot (if connected? 'on 'off)) (badge (if connected? "Connected" "Not connected") (if connected? 'ok 'warn))) (kv-table (list (cons "Transport" transport-url) (cons "Status endpoint" "same origin /node/status")))) (card "Runtime" (h-stack (icon-dot (if (eq? (cdr summary) 'ok) 'on 'off)) (badge (car summary) (cdr summary))) (kv-table (list (cons "Platform" (field platform kind: "unknown")) (cons "Environment" (field platform environment: "unknown")) (cons "PID" (field process pid: "absent")) (cons "PID state" (field process pid-state: "unknown"))))) (card "Role and capabilities" (kv-table (list (cons "Role" (field identity role: "unknown")) (cons "Scope" (field identity scope: "unknown")) (cons "Resource" (capability-state resource)) (cons "Session" (capability-state session)) (cons "Agent" (capability-state agent)) (cons "Renderer" (capability-state renderer)) (cons "Remote transport manager" (capability-state remote-manager)) (cons "Remote transport reason" (field remote-manager reason: "unknown")))) `(p (@ (class "welcome-note")) "Capability availability does not grant the current origin permission to use it.")) (card "Persistent installation" (kv-table (list (cons "Installation" (field installation posture: "unknown")) (cons "Supervisor" (field supervision kind: "none")) (cons "Supervisor running" (field supervision running: "unknown")) (cons "Configured listener" (field listener url: "unknown")) (cons "Autostart" (field autostart posture: "unknown")) (cons "Record" (field lifecycle state: "missing")) (cons "Restarts" (field lifecycle restart-count: "0"))))) (card "Node upgrade" (kv-table (list (cons "Current version" (field upgrade current-version: "unknown")) (cons "Available version" (field upgrade available-version: "not checked")) (cons "State" (field upgrade state: "unknown")) (cons "Check endpoint" (field upgrade-check endpoint: "unavailable")) (cons "Install strategy" (field upgrade-installation strategy: "unknown")) (cons "Apply" (if (dict-ref upgrade-apply available: #f) "available" (field upgrade-apply reason: "unavailable"))) (cons "Rollback" (if (dict-ref upgrade-rollback available: #f) "available" (field upgrade-rollback reason: "unavailable"))))) `(p (@ (class "welcome-note")) "Upgrade checks are read-only. Applying or rolling back an upgrade is not implemented yet.")) (card "Web client" (kv-table (list (cons "Running version" (field client running-version: "unknown")) (cons "Controlling worker" (field client controlled-version: "unknown")) (cons "Served worker" (field client served-version: "unknown")) (cons "Worker state" (field client controller-state: "unknown")) (cons "Worker script" (field client controller-script: "unknown")) (cons "Worker cache" (field client controlled-cache: "unknown")) (cons "Slate caches" (field client cache-names: "unknown")) (cons "Origin" (field client origin: "unknown")))) (button "Update client" "slate-update-client" 'ghost)) (card "Application" (kv-table (list (cons "Selection" (field app selection: "unknown")) (cons "Upstream" (field app url: "unknown")) (cons "Schema" (field status schema-version: "unknown"))))))))))))) (define-mode node-status glyph: "●" icon: "activity" lighter: "node" info: (lambda (v) "local node status") render: node-status-render) (define (make-node-status-view) (node-status slate/name: node-status-view-name slate/source: #{ kind: file: provider: slate: address: "/node-status" } slate/node-status-phase: loading: slate/node-status: #{} slate/node-status-error: "" slate/node-connected?: #f slate/node-transport-url: "unknown")) (define (node-status-loading v) (dict-set (dict-set v slate/node-status-phase: loading:) slate/node-status-error: "")) (define (node-status-ready v status . connection) (let ((ready (dict-set (dict-set v slate/node-status-phase: ready:) slate/node-status: status))) (if (null? connection) ready (node-status-connection ready (car connection) (if (null? (cdr connection)) "unknown" (cadr connection)))))) (define (node-status-connection v connected? transport-url) (dict-set (dict-set v slate/node-connected?: connected?) slate/node-transport-url: transport-url)) (define (node-status-failed v message) (dict-set (dict-set v slate/node-status-phase: error:) slate/node-status-error: message))))packages/slate/src/slate/modes/terminal.sglmodified
(slate base64) ; OSC-52 payload is base64 (§10) (slate tmux opener)) ; tmux-render for the CC "better terminal" pane (export make-terminal-view terminal? terminal terminal-send-key! terminal-paste! terminal-scroll! terminal-set-size! terminal-close! terminal-live? terminal-take-frame terminal-registered? terminal-window terminal-send-key! terminal-paste! terminal-compose! terminal-scroll! terminal-set-size! terminal-close! terminal-live? terminal-remote-id terminal-copy-snapshot terminal-copy-active? terminal-copy-end! terminal-take-frame terminal-frame-pending? terminal-registered? terminal-window terminal-set-prediction-enabled! terminal-take-prediction-input! key-event->bytes ;; the ONE cursor-key spelling (DECCKM + xterm modifier parameter) and ;; the ONE scroll-gesture decision (wheel + touch-drag share both) ;; uncovered by any unit test, and is now SAID rather than implied, is ;; this module's wiring of that decision to the DOM and the gesture. terminal-note-time! terminal-mark-connected! terminal-take-visual-damage! terminal-disconnected? disconnect-ribbon terminal-redial! terminal-note-clock-live! term-mode-badge) (begin ;; term inside is the mutable machine. (define *terminals* #{}) (define (terminal-remote-id name) (let ((e (term-entry name))) (and e (session-live? (dict-ref e handle: #{})) (session-remote-id (dict-ref e handle: #{}))))) (define (term-entry name) (dict-ref *terminals* name #f)) (define (set-entry! name e) (set! *terminals* (dict-set *terminals* name e))) (define (update-entry! name k v) ;; backings) is R3b's. (define (disconnect-ribbon name) (let ((label (ribbon-label name))) (if label (list ;; Keep this keyed slot in the pane in both connected and disconnected ;; states. The sibling .term-content is a foreign subtree owned by the ;; JS terminal kernel; inserting a new sibling ahead of it let the vdom ;; reconcile through that node and erase the retained terminal grid. (list `(div (@ (class "term-ribbon-slot") (key "term-ribbon-slot")) ,(if label ;; A REAL <button>, not a styled span. A span has no tab stop, no ;; Enter/Space activation and no button role, so the only recovery ;; gesture this phase ships would have been reachable by POINTER (data-term-ribbon-action ,name) (data-slate-click ,(string-append "terminal-reconnect-pane:" name))) "Reconnect"))) '()))) "New Session")) ""))))) ;; The ribbon's clock. Fed from the same metronome as everything else, and ;; kept OUT of the app-db deliberately: the terminal registry is not state, ;; because someone is on a bad mobile link). (define *now-ms* #f) (define *ribbon-signature* "") ;; Async session events mutate the terminal registry outside Slate's normal ;; dispatch/render path. Remember transitions that change DOM chrome so the ;; owning metronome can render them explicitly. The terminal canvas pump is ;; insufficient here: the disconnected dimmer and ribbon live in the Slate ;; tree, not in the emulator canvas. (define *visual-damage?* #f) (define (terminal-take-visual-damage!) (let ((damaged? *visual-damage?*)) (set! *visual-damage?* #f) damaged?)) ;; IS EITHER METRONOME ARMED? A FACT, NOT A CLOCK READING, and the distinction ;; is the whole repair of this surface's worst defect. ;; grid, which is the mirror image of the lie it exists to prevent. Leaving ;; the stale signature in place is precisely what triggers the removal. (define (terminal-mark-connected! name) (let ((e (term-entry name))) ;; Keep the registry read behind a multi-incoming merge. In optimized ;; WASM, reading *terminals* through term-entry/update-entry and then ;; replacing that same cell was flow-insensitively aliased to the value ;; being written. The emitted function consequently kept the guard and ;; discarded BOTH field updates: the remote session reattached, but its ;; pane stayed dimmed until an unrelated view switch forced a render. ;; `or` is the established barrier used by the two latches immediately ;; above; every dict is truthy, so it changes the CFG but not the value. ;; Build the complete replacement from that snapshot and set the registry ;; once, which also makes the state transition atomic to event readers. (let* ((registry (or *terminals* #{})) (e (dict-ref registry name #f))) (when (and e (dict-ref e disconnected?: #f)) (update-entry! name disconnected?: #f) (update-entry! name disconnected-at: #f)))) (set! *terminals* (dict-set registry name (dict-set (dict-set e disconnected?: #f) disconnected-at: #f))) (set! *visual-damage?* #t)))) ;; ---- OSC-52 staged clipboard (§10) --------------------------------------- ;; An inner program (or tmux copy-mode via the opener) that writes the OSC 52 (define default-cols 80) (define default-rows 24) ;; Only actual terminal key writes enter this UI handoff. Key bindings, ;; sticky modifiers and the mobile input route have already resolved here. ;; A single pending item suffices: the browser drains after each key event. (define *prediction-enabled?* #f) (define *prediction-input* "") (define (terminal-set-prediction-enabled! enabled) (unless (eq? enabled *prediction-enabled?*) (set! *prediction-input* "")) (set! *prediction-enabled?* enabled)) (define (terminal-take-prediction-input!) (let ((input *prediction-input*)) (set! *prediction-input* "") input)) (define (note-prediction-input! name bytes) (when *prediction-enabled?* (let ((printable (and (= (string-length bytes) 1) (<= 32 (char->integer (string-ref bytes 0)) 126)))) (set! *prediction-input* (string-append name "\n" (number->string (dict-ref (term-entry name) output-serial: 0)) "\n" (if printable bytes "")))))) ;; Feed a chunk + drain any queued emulator replies (DSR/DA) back to the ;; pty, then replenish the credit window by exactly what we consumed. ;; Chunks that arrive BEFORE the session handle exists (a provider that ;; user's clear way back is unchanged: any keystroke snaps to live ;; (terminal-send-key!), Shift-PageDown/End walk forward. (define (feed-pinned! name t bytes) (when *prediction-enabled?* (update-entry! name output-serial: (+ 1 (dict-ref (term-entry name) output-serial: 0)))) (let ((off (dict-ref (term-entry name) offset: 0))) (if (or (= off 0) (term-alt? t)) (term-feed! t bytes) ;; (dict-ref ev code: #f) would silently drop a SIGKILL'd process's exit. (define exit-missing (list 'no-exit)) ; a unique sentinel (define (on-session-event name ev) ;; Preserve lifecycle invalidation until a frame consumes it. A lost ;; connection and successful reattach can both arrive before the next ;; paint; the final connected flag alone would hide that interruption. (when (and *prediction-enabled?* (not (dict-ref ev bytes: #f)) (term-entry name)) (update-entry! name prediction-reset?: #t)) (cond ;; data chunk. Bytes arriving ARE the reconnection: whatever the pane ;; was told earlier, a stream that is delivering is not disconnected, so ;; calls `slate-refresh`. This module cannot call it anyway -- (slate ;; main) imports this one. ((dict-ref ev reattached: #f) (terminal-mark-connected! name)) (let ((e (term-entry name)) (replayed (dict-ref ev replayed-bytes: #f))) ;; A non-empty replay is the node's authoritative bounded terminal ;; history, not new output. Clear the retained emulator before the ;; provider emits that ordered frame, otherwise every reconnect ;; appends the same history and makes old commands appear to run ;; again. Zero deliberately preserves the retained screen because ;; no replay frame follows; #f is a legacy/unknown node and cannot ;; safely authorize a destructive reset. (when (and e (number? replayed) (> replayed 0)) (term-reset! (dict-ref e term: #f)) (update-entry! name offset: 0)) (terminal-mark-connected! name))) ;; exit: mark + paint a closing line into the grid (visible history) ((not (eq? (dict-ref ev code: exit-missing) exit-missing)) (let ((e (term-entry name)) (shift? (dict-ref props shift-key: #f)) (t (dict-ref e term: #f))) (cond ((dict-ref e copy-active?: #f) #t) ;; Cmd/Meta chords belong to the BROWSER/OS (Cmd+C copy, ;; Cmd+V paste, Cmd+W…): never encode Meta as Ctrl here — ;; sending ^C on Cmd+C would kill the user's foreground job, (when (> (dict-ref e offset: 0) 0) ; snap to live (update-entry! name offset: 0) (mark-view-all! name)) (note-prediction-input! name bytes) (session-write (dict-ref e handle: #{}) bytes) #t)))))))))) (define (terminal-paste! name text) (let ((e (term-entry name))) (when e (when (and e (not (dict-ref e copy-active?: #f))) (let ((t (dict-ref e term: #f)) (h (dict-ref e handle: #{})) (s (normalize-paste text))) (session-write h s))))) #t) ;; Compose is a paste, never a key chord or an implicit command submission. ;; Refuse a known disconnected/dead target so the host can retain its draft. (define (terminal-compose! name text) (and (string? text) (> (string-length text) 0) (terminal-live? name) (not (terminal-disconnected? name)) (not (terminal-copy-active? name)) (guard (e (#t #f)) (update-entry! name prediction-reset?: #t) (terminal-paste! name text)))) ;; ---- mouse forwarding (pointer events -> pty) -------------------------------- ;; The RECEIVE half lives in the emulator: DECSET ?1000/1002/1003/1006/1007 ;; set term-mouse-flags (sigil-vt native core). This is the SEND half, ;; otherwise, so the caller keeps its local no-forward behaviour. (define (terminal-send-mouse! name kind button col row shift? alt? ctrl?) (let ((e (term-entry name))) (and e (and e (not (dict-ref e copy-active?: #f)) (let ((flags (term-mouse-flags (dict-ref e term: #f)))) (and (> (bitwise-and flags mouse-track-mask) 0) ;; motion is only reported under button-event (1002) / (define (terminal-scroll-gesture! name lines notches col row shift? alt? ctrl?) (let ((e (term-entry name))) (if (not e) (if (or (not e) (dict-ref e copy-active?: #f)) "" (let* ((t (dict-ref e term: #f)) (case- (scroll-gesture-case (term-alt? t) (term-mouse-flags t)))) ;; history row (ring index 0) is distance 1. (term-scrollback-runs t (- (- off i) 1) cursor-col)))) ;; A one-shot snapshot of the retained rows, oldest first. HTML uses the ;; same escaped renderer as live output. The host owns selection over these ;; immutable rows while incoming bytes continue feeding the live emulator. (define (terminal-copy-snapshot name) (let ((e (term-entry name))) (if (not e) "" (let* ((t (dict-ref e term: #f)) (history (if (term-alt? t) 0 (term-scrollback-count t))) (rows (term-rows t)) (top (max 0 (- history (dict-ref e offset: 0)))) (text (call-with-output-string (lambda (port) (display (term-cols t) port) (display " " port) (display rows port) (display " " port) (display top port) (display " " port) (display (if (= top history) (+ history (term-cursor-row t)) top) port) (display " " port) (display (if (= top history) (term-cursor-col t) 0) port) (let loop ((i 0)) (when (< i (+ history rows)) (newline port) (write-string (row-html (if (< i history) (term-scrollback-runs t (- history i 1) #f) (term-row-runs t (- i history) #f))) port) (loop (+ i 1)))))))) (update-entry! name copy-active?: #t) text)))) (define (terminal-copy-active? name) (let ((e (term-entry name))) (and e (dict-ref e copy-active?: #f)))) (define (terminal-copy-end! name) (when (term-entry name) (update-entry! name copy-active?: #f) (update-entry! name offset: 0) (mark-view-all! name)) #t) ;; ---- the frame protocol (drained by the JS TERM kernel at rAF pace) ------------- ;; Non-destructive damage probe for the browser input shim. A synchronous ;; provider (echo-pty) can feed the emulator during session-write, whereas a ;; remote provider cannot produce damage until a later socket event. Keeping ;; this beside terminal-take-frame lets the accessory-key contract distinguish ;; those cases without naming or special-casing either transport. (define (terminal-frame-pending? name) (let ((e (term-entry name))) (and e (term-damaged? (dict-ref e term: #f))))) ;; Returns "" when nothing changed. Otherwise: ;; line 0: cols rows cursor-row cursor-col cursor-vis(0/1) offset exited(0/1) ;; prediction-safe(0/1) output-serial ;; optional p line: p\t<authoritative cursor-row text> ;; line N: <viewport-row-index>\t<row inner-HTML> ;; `full?` forces a complete repaint (kernel adoption / vdom wipe). (define (terminal-take-frame name full?) (cur-vis (and (term-cursor-visible? t) (= off 0) (not (dict-ref e exited?: #f)))) ;; R4 predictive echo is an OVERLAY decision, but its safety ;; posture belongs beside the authoritative VT state. The ;; browser never infers these modes from rendered cells. ;; Tmux uses alternate screen, mouse tracking and application ;; cursors even at a shell prompt. Those modes do not imply ;; no echo: the predictor must learn exact row/cursor echoes ;; before previewing any text, just as on the main screen. (prediction-safe (and cur-vis (not (dict-ref e disconnected?: #f)) (not (dict-ref e prediction-reset?: #f)) (< cur-col (- (term-cols t) 1)))) (serial (dict-ref e output-serial: 0)) (prediction-state (list prediction-safe serial)) (prediction-changed (and *prediction-enabled?* (not (equal? prediction-state (dict-ref e last-prediction-state: #f))))) (last-cur (dict-ref e last-cursor: (list -1 -1 #f))) (cur-moved (not (equal? last-cur (list cur-row cur-col cur-vis)))) (drows (dict-ref dmg rows: '()))) (if (and (not all?) (null? drows) (not cur-moved)) (if (and (not all?) (null? drows) (not cur-moved) (not prediction-changed)) "" (let* ((dirty (if all? (cons (+ off cur-row) base)) base))))) (update-entry! name last-cursor: (list cur-row cur-col cur-vis)) (update-entry! name last-prediction-state: prediction-state) (when (dict-ref e prediction-reset?: #f) (update-entry! name prediction-reset?: #f)) (call-with-output-string (lambda (port) (write-string (number->string (term-cols t)) port) (write-string (number->string off) port) (write-string " " port) (write-string (if (dict-ref e exited?: #f) "1" "0") port) (write-string " " port) (write-string (if prediction-safe "1" "0") port) (write-string " " port) (write-string (number->string serial) port) ;; This is the authoritative row, not DOM text or a ;; guessed suffix. The browser rejects non-ASCII rows, ;; so byte, character and cell positions cannot diverge. (when *prediction-enabled?* (write-string "\np\t" port) (write-string (term-row-text t cur-row) port)) ;; emit each dirty viewport row once, in-range only (let ((seen (make-vector rows #f))) (for-each commands: (lambda (v) (list (make-cand "Terminal: Toggle Char/Line Mode" "C-M-t" "terminal-toggle-mode") (make-cand "Terminal: Close" "" "terminal-close") ;; The ribbon's Reconnect affordance, reachable with no ;; The ribbon's New Session affordance, reachable with no ;; pointer. The ribbon only appears on a bad link, which ;; is exactly when a touch target may be the thing the ;; user cannot manage. (make-cand "Terminal: Reconnect (Fresh Session)" "" "terminal-reconnect") (make-cand "Terminal: New Session" "" "terminal-reconnect") (make-cand "Terminal: Scroll to Live" "" "terminal-live") (make-cand "Tmux: Next Window" "M-}" "tmux-next-window") (make-cand "Tmux: Previous Window" "M-{" "tmux-prev-window") ;; The accessory-bar Terminal category — the §3.3 payoff: every key here is a ;; plain `key:` SPEC. A tap synthesizes the keydown and enters slate-handle-key, ;; where the terminal's `default: "terminal:send-key"` catch-all routes it to ;; the pty — Esc/Tab/C-c/arrows/C-d/C-r "just work" with ZERO terminal-specific ;; bar code. Close is the one declarative command. ;; the pty. Esc, Tab and arrows live in the fixed key grid; this menu keeps ;; composition, common control chords and Close. actions: (list #{ id: 'terminal label: "Term" glyph: "⌁" tier: 'primary items: (list #{ id: 'esc label: "Esc" key: "Escape" tier: 'primary } #{ id: 'tab label: "Tab" key: "Tab" tier: 'primary } #{ id: 'compose label: "Compose Input" host-action: "terminal-compose:open" tier: 'primary } #{ id: 'ctrl-c label: "C-c" key: "C-c" tier: 'primary } #{ id: 'up label: "↑" key: "ArrowUp" tier: 'secondary repeat: #t } #{ id: 'down label: "↓" key: "ArrowDown" tier: 'secondary repeat: #t } #{ id: 'ctrl-d label: "C-d" key: "C-d" tier: 'secondary } #{ id: 'ctrl-r label: "C-r" key: "C-r" tier: 'tertiary } #{ id: 'close label: "Close" command: "terminal-close" tier: 'tertiary }) })) #{ id: 'close label: "Close" command: "terminal-close" tier: 'tertiary }) } #{ id: 'terminal-copy label: "Copy" glyph: "▣" tier: 'secondary items: (list #{ id: 'start label: "Enter copy mode" host-action: "terminal-copy:start" tier: 'primary } #{ id: 'mark label: "Mark" host-action: "terminal-copy:mark" tier: 'primary } #{ id: 'copy label: "Copy selection" host-action: "terminal-copy:copy" tier: 'primary } #{ id: 'cancel label: "Cancel" host-action: "terminal-copy:cancel" tier: 'secondary } #{ id: 'received label: "Copy received text" host-action: "terminal-copy:received" tier: 'secondary }) })) (define (term-mode-badge v leaf) (if (terminal? v) (let ((char? (terminal-char-mode? (view-name v)))) (title ,(if char? "CHAR mode — keys go to the app · click or C-M-t → LINE" "LINE mode — Slate keybindings active · click or C-M-t → CHAR"))) ,(if char? "CHAR" "LINE")))) ,(if char? "CHAR" "LINE")) `(span (@ (class "terminal-copy-label") (hidden "hidden"))) ;; Empty browser-owned controls keep desktop copying available ;; inline in the existing header, without a dedicated row. `(span (@ (class "terminal-copy-header-tools") (key "terminal-copy-header-tools"))))) '())) ))packages/slate/src/slate/modes/welcome.sglmodified
(string-append "openroot:" (root-name-string name))))) (define (navigable-roots) (filter (lambda (n) (not (eq? n slate:))) (root-names))) (filter (lambda (n) (not (eq? n slate:))) (root-visible-names))) ;; "Connect this computer" — the primary node-pairing affordance ;; ([[topics/slate-distribution-architecture]] §1.1). Shown only until the ;; event round-trip through wasm would lose the user-gesture context the ;; popup requires). No-op in a build with no node bridge (plain browser). (define (node-connect-card) (if (root-registered? local:) (if (any (lambda (name) (dict-ref (root-ref name) local?: #f)) (root-names)) '() (list `(div (@ (class "welcome-connect")) (button (@ (class "node-pair-btn") (type "button"))packages/slate/src/slate/prompt.sglmodified
prompt-type prompt-label prompt-candidates prompt-normalize prompt-validate prompt-empty-action prompt-on-accept prompt-confirm? prompt-completes? prompt-rank prompt-completes-query? prompt-preview prompt-on-cancel prompt-history prompt-preview prompt-on-cancel prompt-history prompt-candidate-policy prompt-accept confirm-reminder open-prompt close-prompt) (begin ;; Prompt activation lifecycle belongs to the prompt domain. State remains ;; an opaque value: these helpers only maintain the prompt-owned fields. (define (open-prompt state prompt stack) (dict-set (dict-set (dict-set (dict-set (dict-set (dict-set state slate/minibuffer: prompt) slate/prompt-stack: stack) slate/selector-query: "") slate/selector-index: 0) slate/history-pos: #f) slate/history-stash: "")) (let* ((s1 (dict-set state slate/minibuffer: prompt)) (s2 (dict-set s1 slate/prompt-session: (+ 1 (dict-ref state slate/prompt-session: 0)))) (s3 (dict-set s2 slate/prompt-stack: stack)) (s4 (dict-set s3 slate/selector-query: "")) (s5 (dict-set s4 slate/selector-index: 0)) (s6 (dict-set s5 slate/history-pos: #f))) (dict-set s6 slate/history-stash: ""))) (define (close-prompt state) (dict-set (dict-set (dict-set (dict-set state slate/minibuffer: #f) normalize: query-verbatim validate: always-valid empty-action: #f candidate-policy: live: rank: #f preview: #f on-cancel: #f ;; wants free text supplies an `empty-action:`; that is exactly what find-file ;; and the web-url prompt are. (define (completing-read-prompt label candidates on-accept) (dict-set (dict-set (base-prompt 'completing-read label) candidates: candidates) on-accept: on-accept)) (dict-set (dict-set (dict-set (base-prompt 'completing-read label) candidates: candidates) candidate-policy: snapshot:) on-accept: on-accept)) ;; read-file-name: completing-read over a directory listing, plus the two things ;; only a path prompt needs. ;; Built ON completing-read (it IS one, with a path table), so it must restate ;; its own `type:` — the base it extends carries 'completing-read. (define (file-prompt label candidates on-accept normalize empty-action) (dict-set (dict-set (dict-set (completing-read-prompt label candidates on-accept) type: 'file) normalize: normalize) (dict-set (dict-set (dict-set (dict-set (completing-read-prompt label candidates on-accept) type: 'file) normalize: normalize) candidate-policy: directory:) empty-action: empty-action)) ;; read-string: free text, no candidate list, and the QUERY itself is the result ;; ranker. Search's is the identity: its `candidates:` closure has already matched ;; against the query and returned rows in buffer order. (define (prompt-rank p) (dict-ref p rank: #f)) (define (prompt-candidate-policy p) (dict-ref p candidate-policy: live:)) ;; Does Tab COMPLETE THE QUERY toward the selected row's label? ;;packages/slate/src/slate/prompt/candidates.sglmodified
(list (cand "View: Switch" "C-x b" "prompt:view") (cand "Root: Switch…" "C-x r" "prompt:root") (cand "Slate: Welcome" "the home base · slate:/welcome" "welcome") (cand "Slate: Node Status" "local lifecycle and listener facts" "node-status") (cand "Slate: Keep Screen Awake" "toggle the client wake lock" "slate-keep-screen-awake") (cand "Notifications: Open" "the notification inbox · slate:/notifications" "show-notifications") (cand "Scratchpad: Open" "slate:/scratchpad" "scratchpad") (cand "Folder: Open" "C-x d" "open-folder") (root-annotation n) (string-append "openroot:" (root-name-string n)) (or (and r (dict-ref r glyph: #f)) "▸")))) (root-names))) (root-visible-names))) ;; Theme switcher candidates: the whole catalog, filterable by name; the glyph ;; marks the scheme (● dark / ○ light) and the active theme reads "current".packages/slate/src/slate/provider.sglmodified
source-materialize source-persist source-subscribe source-stat source-copy source-rename source-mkdir source-trash source-restore source-delete source-git-request ;; Session plane: opening + the live-session handle ops source-session-open session-write session-resize session-signal session-detach session-attach session-close session-credit session-open? session-live? session-window session-retire session-credit session-open? session-live? session-remote-id session-window session-retire ;; default initial credit window (bytes) *default-window*) (begin ((provider-op source restore:) source on-ready on-error)) (define (source-delete source on-ready on-error) ((provider-op source delete:) source on-ready on-error)) ;; Provider-neutral Git request. The selected source/root owns naming and ;; destination; providers lower the verb onto their correlated node lane. (define (source-git-request source verb args on-ready on-error) ((provider-op source git-request:) source verb args on-ready on-error)) ;; ---- Session plane: registry + credit flow control ----------------------- ;; Sessions are STATEFUL, so unlike the coarse ops they get a registry. Each ;; succeeded, and if that ever happens it is a defect to surface rather than ;; a state to quietly repair. (define (make-reattached sid) (lambda (replayed-bytes at-ms) (lambda (replayed-bytes at-ms . maybe-transport) (let ((s (session-unclosed sid))) (when s (let ((on-event (dict-ref s on-event: #f))) (when on-event (on-event #{ session: sid reattached: #t replayed-bytes: replayed-bytes at-ms: at-ms }))))))) (let ((event #{ session: sid reattached: #t replayed-bytes: replayed-bytes at-ms: at-ms })) (on-event (if (pair? maybe-transport) (dict-set event transport: (car maybe-transport)) event))))))))) ;; Open a session on a stream-source. `on-event` receives data / exit / err ;; event dicts (§2.2). `opts` may carry `window:` (initial credit) — default (if (and s (not (dict-ref s closed?: #f))) (dict-ref s obj: #{}) #{}))) (define (session-remote-id handle) (let ((op (session-op handle remote-id:))) (and op (op)))) (define (session-op handle key) (dict-ref (session-obj handle) key #f)) (define (session-write handle bytes)packages/slate/src/slate/providers/enclave-node.sglmodified
;;; (slate providers enclave-node) — remote node resource provider over Enclave.(define-library (slate providers enclave-node) (import (sigil core) (sigil math) (import (sigil math) (sigil io) (sigil json) (sigil string) (sigil async) (sigil wire) (sigil websocket) (sigil browser local-storage) (sigil irc message) (sigil log) (slate base64) (slate provider) (slate resource transfer) (slate root) (slate agent remote) (slate providers node) (slate providers enclave-node carrier) (slate providers enclave-node resource) (slate enclave-account session)) (export enclave-node-register! enclave-node-on-message! enclave-node-poll! enclave-node-on-persist-error! enclave-node-reset! enclave-node-set-local-identity! enclave-node-register-local-root! enclave-node-register-pairings! enclave-node-on-persist-error! enclave-node-on-transfer-progress! enclave-node-transfer-count enclave-node-reset! ;; R3a: callback deadlines, and failing in-flight work on a lost link enclave-node-tick! enclave-node-fail-inflight! enclave-node-account-lost! enclave-node-rpc-timeout-ms enclave-node-pending-count ;; Pure, and exported on the same grounds as open-reply-binding above: ;; a unit with its own contract, not a transport seam opened for a ;; cannot distinguish "never came due" from "issued, reply lost". enclave-node-reattach-debug enclave-node-stream-count enclave-node-carrier-transport enclave-node-performance-trace! enclave-node-performance-trace-poll ;; TWO nouns, deliberately — see the block above `*handles*`. enclave-node-held-sids enclave-node-stream-sids ;; Pure, exported on the same grounds: the LOWERING of a source's ;; the leg that makes the capability set worth having: a client with ;; several discovered nodes cannot probe each one for a fact the node ;; already knows. enclave-node-sys-info! enclave-node-stream-ticket! enclave-node-sys-info! discovery-pending-after-registration registry-cache->json registry-cache<-json enclave-node-stream-ticket! ;; The pump's LOSS path, exported on exactly the grounds ;; `remote-session-open` is: "the tick calls these; tests call ;; directly". This is where a dead stream ARMS the re-attach ladder, ;; module isolation — provider-test.sgl already poisons whatever runs ;; after it by exactly that mechanism. A test for a refusal should not ;; be able to redden an unrelated file. remote-session-open) remote-session-open enclave-node-list-sessions!) (begin (define *next-id* 0) (define *callbacks* #{}) (define *resource-transfers* (make-resource-transfer-registry)) (define *on-transfer-progress* #f) (define *subscriptions* #{}) (define *carriers* (make-carrier-registry)) (define *streams* '()) (define *performance-trace?* #f) (define *performance-trace-seq* 0) (define *performance-trace-results* '()) (define *discovered-roots* '()) (define *discovery-roots* '()) ;; Roots learned from the authenticated local node's durable pairing ;; ledger survive an unavailable account catalog. Keep the set separate: ;; ENCLAVE-NODES snapshots are authoritative for account membership, but ;; cannot be authoritative for a machine-local relationship they cannot ;; observe while the hub is down. (define *paired-roots* '()) (define *pairing-generation* 0) (define *on-discovery* (lambda () #f)) (define *discovery-pending* 0) (define *discovery-ended?* #f) (define *discovery-generation* 0) (define *on-persist-error* #f) (define *local-node-identity* #f) ;; Machine-local, best-effort discovery snapshot. This is deliberately not ;; the Slate store directory: the catalog is needed before any named node is ;; reachable and is scoped to this browser installation, like account and ;; loopback credentials. It contains no tickets, pairing keys or endpoints. (define registry-cache-key "slate.node/catalog-v1") (define (cache-entry-valid? entry) (and (dict? entry) (string? (dict-ref entry label: #f)) (> (string-length (dict-ref entry label: "")) 0) (string? (dict-ref entry nick: #f)) (> (string-length (dict-ref entry nick: "")) 0))) (define (registry-cache->json entries) (json-encode #{ version: 1 entries: entries })) (define (registry-cache<-json text) (guard (e (#t '())) (let* ((doc (and (string? text) (> (string-length text) 0) (json-decode text))) (raw (and (dict? doc) (= (dict-ref doc version: 0) 1) (dict-ref doc entries: '())))) (filter cache-entry-valid? (cond ((array? raw) (array->list raw)) ((list? raw) raw) (else '())))))) (define (registry-cache-load) (registry-cache<-json (guard (e (#t #f)) (local-storage-get registry-cache-key)))) (define (registry-cache-save!) (let ((entries (filter-map (lambda (name) (let ((root (root-ref name))) (and root (eq? (dict-ref root class: #f) machine:) (string? (dict-ref root host: #f)) #{ label: (dict-ref root label: (root-name-string name)) nick: (dict-ref root host: "") home: (dict-ref root home: "") online: (eq? (dict-ref root status: #f) online:) }))) (append *discovery-roots* *paired-roots*)))) (guard (e (#t #f)) (local-storage-set! registry-cache-key (registry-cache->json entries))))) (define (register-cached-roots!) (for-each (lambda (entry) (let* ((label (dict-ref entry label: "")) (nick (dict-ref entry nick: "")) (home (dict-ref entry home: "")) (name (string->keyword label))) (unless (root-registered? name) (register-root! name (let ((root #{ provider: (if (local-node-identity? nick) local: node:) host: nick label: label glyph: "⌂" class: machine: local?: (local-node-identity? nick) status: unknown: planes: (list resource: session:) })) (if (and (string? home) (> (string-length home) 0)) (dict-set root home: home) root)))) (unless (member name *discovered-roots*) (set! *discovered-roots* (cons name *discovered-roots*))))) (registry-cache-load))) (define *now-ms* 0) (define (local-node-identity? nick) (and (string? *local-node-identity*) (string? nick) (not (string=? *local-node-identity* "")) (string=? *local-node-identity* nick))) ;;; Locality is attested by the authenticated loopback node, not by the ;;; account catalog. Once that node has published its canonical machine ;;; root, a later discovery snapshot may refresh its presence and label but ;;; must not replace its provider/host/home with catalog data. Requiring ;;; both the remembered opaque identity and the existing local root keeps a ;;; mere display-label collision from claiming locality. (define (canonical-local-root? name) (let ((root (root-ref name))) (and root (eq? (dict-ref root class: #f) machine:) (eq? (dict-ref root provider: #f) local:) (dict-ref root local?: #f) (local-node-identity? (dict-ref root host: #f))))) ;;; Identity, not display label, decides locality. Reclassify an already ;;; discovered root too because the account and loopback sockets race at ;;; startup and either answer may arrive first. (define (enclave-node-set-local-identity! identity) (when (and (string? identity) (not (string=? identity ""))) (set! *local-node-identity* identity) (for-each (lambda (name) (let ((root (root-ref name))) (when (and root (local-node-identity? (dict-ref root host: #f))) (register-root! name (dict-set (dict-set (dict-set root provider: local:) local?: #t) glyph: "⌂")) (when (root-registered? local:) (root-set! local: hidden?: #t))))) (root-names))) #t) ;;; Publish the enrolled local machine without waiting for account-plane ;;; discovery. The loopback node supplies all three facts: opaque identity, ;;; owner-facing registered label, and the already grant-checked home. ;;; Later ENCLAVE-NODES discovery refreshes this same name in place. (define (enclave-node-register-local-root! identity label home) (and (string? identity) (not (string=? identity "")) (string? label) (not (string=? label "")) (string? home) (not (string=? home "")) (let ((name (string->keyword label))) (enclave-node-set-local-identity! identity) (register-root! name #{ provider: local: host: identity label: label glyph: "⌂" class: machine: local?: #t status: online: home: home planes: (list resource: session:) }) (when (and (not (eq? name local:)) (root-registered? local:)) (root-set! local: hidden?: #t)) name))) (define (sequence->list value) (cond ((list? value) value) ((array? value) (array->list value)) (else '()))) (define (first-map proc items) (and (pair? items) (or (proc (car items)) (first-map proc (cdr items))))) ;; Prefer an already registered or cached owner-facing label. A pairing ;; record intentionally contains only transport identity, never account ;; profile metadata, so the opaque nick is the honest cold-start fallback. ;; In particular, never manufacture the misleading label "unknown". (define (paired-root-label nick) (or (first-map (lambda (name) (let ((root (root-ref name))) (and root (equal? (dict-ref root host: #f) nick) (dict-ref root label: #f)))) (root-names)) (first-map (lambda (entry) (and (equal? (dict-ref entry nick: #f) nick) (dict-ref entry label: #f))) (registry-cache-load)) nick)) (define (paired-root-home nick) (or (first-map (lambda (name) (let ((root (root-ref name))) (and root (equal? (dict-ref root host: #f) nick) (dict-ref root home: #f)))) (root-names)) (first-map (lambda (entry) (and (equal? (dict-ref entry nick: #f) nick) (let ((home (dict-ref entry home: #f))) (and (string? home) (> (string-length home) 0) home)))) (registry-cache-load)))) ;; Publish durable peers from local sys/info without waiting for Enclave. ;; Each actionable record is immediately retained in the root catalog and ;; then verified by fs/home through request!'s ordinary selector. With the ;; account lane down that selector can only succeed via paired-direct. (define (enclave-node-register-pairings! pairings) (set! *pairing-generation* (+ *pairing-generation* 1)) (let* ((generation *pairing-generation*) (records (filter (lambda (pairing) (let ((nick (and (dict? pairing) (dict-ref pairing transport-nick: #f)))) (and (string? nick) (> (string-length nick) 0) (not (dict-ref pairing revoked: #t)) (dict-ref pairing offline-ready?: #f) (not (local-node-identity? nick))))) (sequence->list pairings))) (names (map (lambda (pairing) (string->keyword (paired-root-label (dict-ref pairing transport-nick: "")))) records))) (for-each (lambda (name) (unless (or (member name names) (member name *discovery-roots*)) (unregister-root! name) (set! *discovered-roots* (filter (lambda (found) (not (eq? found name))) *discovered-roots*)))) *paired-roots*) (set! *paired-roots* names) (for-each (lambda (pairing) (let ((nick (and (dict? pairing) (dict-ref pairing transport-nick: #f)))) (when (and (string? nick) (> (string-length nick) 0) (not (dict-ref pairing revoked: #t)) (dict-ref pairing offline-ready?: #f) (not (local-node-identity? nick))) (let* ((label (paired-root-label nick)) (name (string->keyword label)) (home (paired-root-home nick))) (register-root! name (let ((root #{ provider: node: host: nick label: label glyph: "⌂" class: machine: local?: #f status: unknown: planes: (list resource: session:) })) (if home (dict-set root home: home) root))) (unless (member name *discovered-roots*) (set! *discovered-roots* (cons name *discovered-roots*))) (request! nick "fs/home" #{} (lambda (result) (when (= generation *pairing-generation*) (let ((path (and (dict? result) (dict-ref result path: #f)))) (root-set! name status: online:) (when (and (string? path) (> (string-length path) 0)) (root-set! name home: path)) (registry-cache-save!) (*on-discovery*)))) (lambda (_error) (when (= generation *pairing-generation*) (root-set! name status: offline:) (*on-discovery*)))))))) records)) #t) ;; How long an unanswered request may sit before its caller is told. ;; ;; Generous, because bulk reads ride this path and a large file over a slow ;; thing that can see it. (define (enclave-node-pending-count) (length (dict-keys *callbacks*))) ;; Secret-free routing status for diagnostics and live gates. The carrier ;; registry deliberately retains only the selected transport and stable ;; fallback class; endpoint advertisements and rendezvous material never ;; reach this surface. (define (enclave-node-carrier-transport host) (let ((entry (and (string? host) (carrier-find-target *carriers* host)))) (and entry (eq? (dict-ref entry state: #f) 'ready) (dict-ref entry transport: #f)))) ;; A root's owner-facing keyword and its opaque transport identity are not ;; interchangeable. Discovery stores the latter in host:, so carrier ;; liveness must join on that field (never `(string->keyword host)`). (define (roots-for-host host) (filter (lambda (name) (let ((root (root-ref name))) (and root (equal? (dict-ref root host: #f) host)))) (root-names))) (define (direct-carrier-ready? host) (eq? (enclave-node-carrier-transport host) 'direct)) (define (mark-direct-host-online! host) (for-each root-mark-online! (roots-for-host host))) ;; Hand every in-flight request its error and clear the table. ;; ;; THE LEAK THIS CLOSES IS THE ONE THAT MATTERED. Nothing walked this table (define (enclave-node-fail-inflight! reason) (let ((pending *callbacks*)) (set! *callbacks* #{}) (for-each (lambda (id) (resource-transfer-cancel! *resource-transfers* id reason)) (dict-keys pending)) (for-each (lambda (id) (let* ((cb (dict-ref pending id #f)) (err (and cb (dict-ref cb err: #f))) (host (and cb (dict-ref cb host: "")))) (when err (when (and err (not (dict-ref cb transfer-direction: #f))) (err (if (and (string? host) (> (string-length host) 0)) (string-append "connection lost to " host) reason))))) (dict-keys pending))) "") ;; Account loss is narrower than provider loss now that a companion can ;; hold hub-independent direct carriers. Preserve callbacks already bound ;; to those carriers and preserve the online state of roots whose direct ;; socket is still ready. Relay/account work still fails immediately. (define (enclave-node-account-lost! reason now-ms) (let* ((pending *callbacks*) (lost-ids (filter (lambda (id) (let ((cb (dict-ref pending id #f))) (and cb (not (eq? (dict-ref cb backend: #f) 'carrier))))) (dict-keys pending)))) (for-each retire! lost-ids) (for-each (lambda (id) (resource-transfer-cancel! *resource-transfers* id reason) (let* ((cb (dict-ref pending id #f)) (err (and cb (dict-ref cb err: #f))) (host (and cb (dict-ref cb host: "")))) (when (and err (not (dict-ref cb transfer-direction: #f))) (err (if (and (string? host) (> (string-length host) 0)) (string-append "connection lost to " host) reason))))) lost-ids)) (for-each (lambda (name) (let* ((root (root-ref name)) (host (and root (dict-ref root host: #f))) (entry (and (string? host) (carrier-find-target *carriers* host))) (direct-ready? (and entry (eq? (dict-ref entry state: #f) 'ready) (eq? (dict-ref entry transport: #f) 'direct)))) (when (and root (eq? (dict-ref root provider: #f) node:) (not direct-ready?)) (root-mark-unknown! name now-ms)))) (root-names)) "") ;; THE SWEEP DECISION, as a pure function over the table. ;; ;; Extracted for the same reason open-reply-binding was, and not as a (lambda (pair) (let ((err (dict-ref (cdr pair) err: #f)) (host (dict-ref (cdr pair) host: ""))) (when err (guard (e (#t #f)) (err (string-append "no reply from " host)))))) (if (dict-ref (cdr pair) transfer-direction: #f) (resource-transfer-cancel! *resource-transfers* (car pair) "transfer timed out") (when err (guard (e (#t #f)) (err (string-append "no reply from " host))))))) expired)) ;; R3b — the re-attach ladder, driven by the same metronome that already ;; carries this provider's clock. It runs AFTER the sweep above, and the (else plain)))) (base64-decode padded))) (define (request! host verb args on-ready on-error) (let* ((id (next-id!)) (payload (json-encode #{ id: id args: args }))) (set! *callbacks* (dict-set *callbacks* id #{ ok: on-ready err: on-error host: host at: (and (> *now-ms* 0) *now-ms*) })) (define (enclave-request! id host verb args on-ready on-error) (let ((payload (json-encode #{ id: id args: args }))) (if (account-session-send-command! "ENCLAVE-RPC" host (number->string id) verb payload) id (define (enclave-node-stream-ticket! host ready error) (let ((id (next-id!))) (set! *callbacks* (dict-set *callbacks* id #{ ticket-ready: ready err: error host: host #{ agent-ticket-ready: ready err: error host: host at: (and (> *now-ms* 0) *now-ms*) })) (unless (account-session-send-command! "ENCLAVE-RPC" host (number->string id) "stream/open" (json-encode #{ id: id args: #{} })) (retire! id) (error "Enclave account is not connected")))) (define (carrier-request-send! id host verb args on-ready on-error) (log-info* "remote carrier requested" mod: "enclave-node" host: host request-id: id verb: verb) (carrier-ensure! *carriers* host (lambda (open) (node-send-raw-text! open) #t) (lambda (entry) (let ((carrier (dict-ref entry carrier: #f)) (cb (dict-ref *callbacks* id #f))) (when cb (set! *callbacks* (dict-set *callbacks* id (dict-set (dict-set cb carrier: carrier) phase: 'awaiting-control))) (let ((direction (dict-ref cb transfer-direction: #f)) (send (lambda (bytes) (node-send-raw-binary! (carrier-frame-encode carrier bytes))))) (cond ((eq? direction 'upload) (resource-transfer-upload! *resource-transfers* id carrier (dict-ref cb transfer-bytes: #f) (dict-ref entry transport: #f) send (lambda (result) (retire! id) (let ((ok (dict-ref cb ok: #f))) (when ok (ok result)))) (lambda (reason) (retire! id) (let ((err (dict-ref cb err: #f))) (when err (err reason)))) (lambda (progress) (when *on-transfer-progress*Showing the first 500 of 1755 diff lines for this file. This diff is INCOMPLETE; read the file or clone the repository for the rest.
packages/slate/src/slate/providers/enclave-node/carrier.sgladded
;;; (slate providers enclave-node carrier) — browser side of the companion's;;; remote carrier multiplex. Pure state and framing; the loopback node provider;;; owns the actual WebSocket.(define-library (slate providers enclave-node carrier) (import (sigil core) (sigil math)) (export GATEWAY-FRAME make-carrier-registry carrier-open! carrier-ensure! carrier-disconnect-all! carrier-on-control! carrier-ref carrier-find-target carrier-remove! carrier-open-request carrier-close-request carrier-text-envelope carrier-frame-encode carrier-frame-decode) (begin (define GATEWAY-FRAME #x11) (define max-u32 4294967295) (define (valid-id? value) (and (integer? value) (> value 0) (<= value max-u32))) (define (write-u32-be! bytes offset value) (bytevector-u8-set! bytes offset (modulo (quotient value 16777216) 256)) (bytevector-u8-set! bytes (+ offset 1) (modulo (quotient value 65536) 256)) (bytevector-u8-set! bytes (+ offset 2) (modulo (quotient value 256) 256)) (bytevector-u8-set! bytes (+ offset 3) (modulo value 256))) (define (read-u32-be bytes offset) (+ (* (bytevector-u8-ref bytes offset) 16777216) (* (bytevector-u8-ref bytes (+ offset 1)) 65536) (* (bytevector-u8-ref bytes (+ offset 2)) 256) (bytevector-u8-ref bytes (+ offset 3)))) ;; Entries remain keyed locally until remote/opened supplies the companion's ;; carrier id. Callbacks are browser-local and never serialized. ;; Carrier-open request ids share the loopback node socket with ordinary ;; local requests. Keep them in the disjoint upper half of the u32 space. (define (make-carrier-registry) (vector 2147483647 '())) (define (entries registry) (vector-ref registry 1)) (define (carrier-ref registry carrier) (let loop ((rest (entries registry))) (and (pair? rest) (let ((entry (car rest))) (if (equal? carrier (dict-ref entry carrier: #f)) entry (loop (cdr rest))))))) (define (carrier-find-target registry target) (let loop ((rest (entries registry))) (and (pair? rest) (let ((entry (car rest))) (if (equal? target (dict-ref entry target: #f)) entry (loop (cdr rest))))))) (define (replace! registry local-id replacement) (vector-set! registry 1 (map (lambda (entry) (if (= local-id (dict-ref entry local-id: 0)) replacement entry)) (entries registry))) replacement) (define (opening-ref registry request) (and (number? request) (let loop ((rest (entries registry))) (and (pair? rest) (let ((entry (car rest))) (if (and (eq? (dict-ref entry state: #f) 'opening) (= request (dict-ref entry local-id: 0))) entry (loop (cdr rest)))))))) (define (remove-entry! registry entry) (vector-set! registry 1 (filter (lambda (item) (not (eq? item entry))) (entries registry))) entry) (define (carrier-open! registry target on-ready on-error) (vector-set! registry 0 (+ 2147483648 (modulo (+ (vector-ref registry 0) 1) 2147483648))) (let ((entry #{ local-id: (vector-ref registry 0) target: target carrier: #f state: 'opening transport: #f fallback-reason: #f ready: on-ready error: on-error })) (vector-set! registry 1 (cons entry (entries registry))) entry)) ;;; Reuse one carrier per target. Opening callers join the entry's waiter ;;; list; ready callers settle synchronously. SEND receives the ordinary ;;; authenticated node request envelope. (define (carrier-ensure! registry target send on-ready on-error . route) (let ((existing (carrier-find-target registry target))) (cond ((and existing (eq? (dict-ref existing state: #f) 'ready)) (when on-ready (on-ready existing)) existing) ((and existing (eq? (dict-ref existing state: #f) 'opening)) (replace! registry (dict-ref existing local-id: 0) (dict-set existing waiters: (append (dict-ref existing waiters: '()) (list #{ ready: on-ready error: on-error }))))) (else (let* ((entry (carrier-open! registry target on-ready on-error)) (with-waiters (replace! registry (dict-ref entry local-id: 0) (dict-set entry waiters: (list #{ ready: on-ready error: on-error }))))) (if (send (carrier-open-request (dict-ref with-waiters local-id: 0) target (and (pair? route) (car route)))) with-waiters (begin (vector-set! registry 1 (filter (lambda (item) (not (eq? item with-waiters))) (entries registry))) (when on-error (on-error "local node is unavailable")) #f))))))) (define (notify-waiters entry key value) (let ((waiters (dict-ref entry waiters: #f))) (if waiters (for-each (lambda (waiter) (let ((callback (dict-ref waiter key #f))) (when callback (callback value)))) waiters) (let ((callback (dict-ref entry key #f))) (when callback (callback value)))))) (define (carrier-remove! registry carrier) (let ((entry (carrier-ref registry carrier))) (when entry (vector-set! registry 1 (filter (lambda (item) (not (equal? carrier (dict-ref item carrier: #f)))) (entries registry)))) entry)) ;;; Atomically invalidate the loopback connection's whole carrier set. ;;; Clear first: waiter callbacks may immediately request replacement work, ;;; and that new work belongs to the next connection generation. (define (carrier-disconnect-all! registry reason) (let ((lost (entries registry))) (vector-set! registry 1 '()) (for-each (lambda (entry) (when (eq? (dict-ref entry state: #f) 'opening) (notify-waiters entry error: reason))) lost) lost)) (define (carrier-open-request request target . route) #{ t: "req" id: request verb: "remote/open" args: (let ((sid (and (pair? route) (car route)))) (if (and (string? sid) (> (string-length sid) 0)) #{ target: target sid: sid } #{ target: target })) }) (define (carrier-close-request carrier) #{ t: "remote/close" carrier: carrier }) (define (carrier-on-control! registry message) (and (dict? message) (equal? (dict-ref message t: #f) "ev") (let ((event (dict-ref message event: #f)) (carrier (dict-ref message carrier: #f))) (cond ((equal? event "remote/opened") (let ((opening (opening-ref registry (dict-ref message request: #f)))) (and opening (valid-id? carrier) (let* ((transport-text (dict-ref message transport: #f)) (transport (cond ((equal? transport-text "direct") 'direct) ((equal? transport-text "relay") 'relay) (else #f)))) (and transport (let ((ready (replace! registry (dict-ref opening local-id: 0) (dict-set (dict-set (dict-set opening carrier: carrier) state: 'ready) transport: transport)))) (notify-waiters ready ready: ready) ;; Opening waiters are one-shot. A later carrier ;; loss belongs to active request/session state, ;; not to the operation that originally opened it. (replace! registry (dict-ref ready local-id: 0) (if (dict-ref ready waiters: #f) (dict-set ready waiters: '()) ready)))))))) ((or (equal? event "remote/failed") (equal? event "remote/closed")) ;; A failed open has no usable carrier yet. Correlate it with ;; the request ticket; established-carrier closure still uses ;; the carrier id. (let ((entry (or (carrier-ref registry carrier) (and (equal? event "remote/failed") (opening-ref registry (dict-ref message request: #f)))))) (and entry (begin (remove-entry! registry entry) (notify-waiters entry error: (if (equal? event "remote/closed") "remote transport closed" "remote transport unavailable")) entry)))) (else #f))))) (define (carrier-text-envelope carrier payload) #{ t: "remote/text" carrier: carrier payload: payload }) (define (carrier-frame-encode carrier payload) (unless (and (valid-id? carrier) (bytevector? payload)) (error "invalid remote carrier frame")) (let* ((length (bytevector-length payload)) (out (make-bytevector (+ length 5) 0))) (bytevector-u8-set! out 0 GATEWAY-FRAME) (write-u32-be! out 1 carrier) (bytevector-copy! out 5 payload 0 length) out)) (define (carrier-frame-decode bytes) (and (bytevector? bytes) (>= (bytevector-length bytes) 5) (= (bytevector-u8-ref bytes 0) GATEWAY-FRAME) (let ((carrier (read-u32-be bytes 1))) (and (valid-id? carrier) #{ carrier: carrier payload: (bytevector-copy bytes 5 (bytevector-length bytes)) }))))))packages/slate/src/slate/providers/enclave-node/resource.sgladded
;;; Binary downloads for browsers without a local companion. Only the small;;; ticket request travels over account RPC; file bodies use /stream.(define-library (slate providers enclave-node resource) (import (sigil io) (sigil json) (sigil wire) (sigil websocket) (slate resource transfer)) (export enclave-resource-download! enclave-resource-poll! enclave-resource-reset! enclave-resource-count) (begin (define *downloads* '()) (define (ref stream key fallback) (dict-ref (vector-ref stream 0) key fallback)) (define (put! stream key value) (vector-set! stream 0 (dict-set (vector-ref stream 0) key value))) (define (enclave-resource-count) (length *downloads*)) (define (settle! stream success? result) (unless (ref stream done: #f) (put! stream done: #t) (guard (error (else #f)) (ws-close (ref stream socket: #f))) (resource-transfer-remove-owner! (ref stream transfers: #f) stream "download closed") (let ((callback (ref stream (if success? ready: error:) #f))) (when callback (callback result))))) (define (enclave-resource-download! url ticket source ready error now) (let ((socket (and (string? ticket) (ws-connect url)))) (if (not socket) (when error (error "image stream connection failed")) (let* ((registry (make-resource-transfer-registry)) (stream (vector #{ socket: socket ticket: ticket source: source ready: ready error: error transfers: registry last-activity: now sent: #f done: #f }))) (resource-transfer-download! registry 1 stream 'relay (lambda (bytes) (ws-send-binary socket bytes)) (lambda (bytes) (settle! stream #t bytes)) (lambda (reason) (settle! stream #f reason)) #f) (set! *downloads* (cons stream *downloads*))))) #t) (define (receive! stream bytes) (let ((frame (transfer-frame-decode bytes))) (when frame (let ((type (dict-ref frame type: 0)) (payload (dict-ref frame payload: #f)) (registry (ref stream transfers: #f))) (cond ((= type 1) (let ((control (json-decode (utf8->string payload)))) (when (= (dict-ref control id: -1) 1) (resource-transfer-control! registry control)))) ;; Older nodes may answer the same read with one bulk frame. ;; It still stays binary and never enters the bounded RPC lane. ((and (= type 16) (= (dict-ref frame id: -1) 1)) (let ((value (wire-decode payload))) (if (bytevector? value) (settle! stream #t value) (settle! stream #f "image stream did not return bytes")))) ((resource-transfer-frame? bytes) (resource-transfer-frame! registry bytes))))))) (define (poll! stream now) (let ((socket (ref stream socket: #f))) (when (and (not (ref stream sent: #f)) (ws-connected? socket)) (ws-send socket (ref stream ticket: "")) (put! stream ticket: #f) (put! stream sent: #t) (ws-send-binary socket (transfer-frame-encode 1 0 (string->utf8 (json-encode #{ t: "req" pv: 2 id: 1 verb: "fs/read-file" args: #{ path: (dict-ref (ref stream source: #{}) address: "") transfer: #t } }))))) (let drain ((remaining 32)) (when (and (> remaining 0) (not (ref stream done: #f))) (let ((message (ws-receive-nowait socket))) (cond ((eq? message 'closed) (settle! stream #f "image stream disconnected")) ((ws-message? message) (put! stream last-activity: now) (when (eq? (ws-message-type message) 'binary) (receive! stream (ws-message-data message))) (drain (- remaining 1))))))) (when (and (not (ref stream done: #f)) (number? now) (number? (ref stream last-activity: #f)) (> (- now (ref stream last-activity: now)) 30000)) (settle! stream #f "image stream timed out")))) (define (enclave-resource-poll! now) (for-each (lambda (stream) (guard (error (else (settle! stream #f "image stream failed"))) (poll! stream now))) *downloads*) (set! *downloads* (filter (lambda (stream) (not (ref stream done: #f))) *downloads*))) (define (enclave-resource-reset!) (let ((pending *downloads*)) (set! *downloads* '()) (for-each (lambda (stream) (settle! stream #f "image stream closed")) pending)))))packages/slate/src/slate/providers/node.sglmodified
(sigil wire) ; wire-decode (bulk 0x10 payloads) (sigil websocket) ; ws-connect / ws-send / ws-send-binary / ws-receive-nowait (slate base64) ; base64-encode (image bytes → data: URL) (slate provider) (slate agent remote)) ; register-provider! (slate agent remote) (slate resource transfer) (slate provider)) ; register-provider! (export node-register! node-available? ;; connect FSM + the JS-facing tick (wrapped in (slate main)) node-connect! node-connect-with! node-poll! node-disconnect! node-phase node-ready? node-connected? node-grants node-instance node-set-token! node-token node-request-pair node-request-retry node-pair-exchange! node-live-sessions node-fetch-home! node-on-sessions! node-pair-exchange! node-live-sessions node-list-sessions! node-fetch-home! node-on-sessions! node-run! node-sys-info! ;; agent-session control plane (agent wire v1) node-agent-fleet! node-agent-settings! node-agent-open! node-agent-list! node-agent-attach! node-agent-child-close! node-drained-output? ;; WS-bytevector binary lane (fs/read-file bytes → JS Blob URL) node-image-via-blob! node-blob-poll node-blob-bytes node-blob-ready node-blob-waiting? node-blob-waiting? node-queue-image! node-blob-mime ;; inbound frame delivery (the tick calls these; tests call directly) node-deliver-text! node-deliver-binary! node-deliver-text! node-deliver-binary! node-deliver-disconnect! ;; Authenticated loopback carrier extension. These preserve the local ;; node protocol while handing only remote/* envelopes to its owner. node-send-raw-text! node-send-raw-binary! node-on-remote-control! node-on-remote-binary! node-on-remote-disconnect! ;; UI hooks — (slate main) wires these to refresh / surface pairing / ;; persist the durable token to origin storage node-on-ready! node-on-pair-needed! node-on-close! node-on-persist-error! node-on-token! node-on-token! node-on-transfer-progress! ;; test / introspection surface node-reset! node-set-transport! node-sent-text node-sent-binary node-clear-sent! node-session-count node-pending-count (define *drained-output* #f) ; did THIS poll deliver pty bytes? (repaint hint) (define *next-id* 0) ; req-id allocator (Resource plane, 32-bit) (define *req-cbs* #{}) ; req-id -> #{ ok: err: } (define *subscriptions* #{}) ; fs/watch req-id -> change callback (define *resource-transfers* (make-resource-transfer-registry)) (define *on-transfer-progress* #f) (define *bulk-waiters* #{}) ; req-id -> ok-callback awaiting its 0x10 frame (define *sessions* #{}) ; daemon sid -> #{ emit: exit: err: carry: closed?: } (define *on-pair-needed* #f) (define *on-close* #f) (define *on-token* #f) ; (tok-or-#f) -> persist to / clear origin storage (define *on-remote-control* #f) (define *on-remote-binary* #f) (define *on-remote-disconnect* #f) (define *transport-generation* 0) (define (node-available?) *available*) (define (node-phase) *phase*) (define (node-on-pair-needed! f) (set! *on-pair-needed* f) "") (define (node-on-close! f) (set! *on-close* f) "") (define (node-on-token! f) (set! *on-token* f) "") (define (node-on-remote-control! f) (set! *on-remote-control* f) "") (define (node-on-remote-binary! f) (set! *on-remote-binary* f) "") (define (node-on-remote-disconnect! f) (set! *on-remote-disconnect* f) "") ;; ---- outbound helpers ---------------------------------------------------- (define (send-text! s) (when *transport* ((dict-ref *transport* send-binary: (lambda (_) #f)) bv))) (define (send-control! dict) (send-text! (json-encode dict))) (define (node-send-raw-text! value) (send-text! (if (string? value) value (json-encode value)))) (define (node-send-raw-binary! bytes) (send-binary! bytes)) ;; Allocate a Resource-plane request id (0 <= id < 2^32) and store callbacks. (define (alloc-req! on-ok on-err) (set! *next-id* (modulo (+ *next-id* 1) 4294967296)) ;; The lower half is reserved for the local node protocol. Companion ;; carrier-open requests use the upper half on this same socket. (set! *next-id* (modulo (+ *next-id* 1) 2147483648)) (let ((id *next-id*)) (set! *req-cbs* (dict-set *req-cbs* id #{ ok: on-ok err: on-err })) id)) (send-control! #{ t: "req" id: id verb: verb args: args }) id)) (define (request-transfer! verb args direction bytes on-ok on-err) (let* ((id (alloc-req! on-ok on-err)) (owner *transport-generation*) (complete (lambda (value) (set! *req-cbs* (dict-set *req-cbs* id #f)) (when on-ok (on-ok value)))) (error (lambda (reason) (set! *req-cbs* (dict-set *req-cbs* id #f)) (when on-err (on-err reason)))) (progress (lambda (value) (when *on-transfer-progress* (*on-transfer-progress* value))))) (set! *req-cbs* (dict-set *req-cbs* id #{ ok: on-ok err: on-err transfer: #t })) (if (eq? direction 'upload) (resource-transfer-upload! *resource-transfers* id owner bytes 'local send-binary! complete error progress) (resource-transfer-download! *resource-transfers* id owner 'local send-binary! complete error progress)) (send-control! #{ t: "req" id: id verb: verb args: args }) id)) ;; ---- inbound: text control frames ---------------------------------------- (define (node-deliver-text! json-str) (let ((msg (guard (e (#t #f)) (json-decode json-str)))) ((equal? t "pair/err") (on-pair-err msg)) ((equal? t "ok") (on-ok msg)) ((equal? t "err") (on-err msg)) ((and (equal? t "ev") (let ((event (dict-ref msg event: ""))) (and (string? event) (string-starts-with? event "remote/")))) (when *on-remote-control* (*on-remote-control* msg))) ((equal? t "remote/text") (when *on-remote-control* (*on-remote-control* msg))) ((equal? t "ev") (on-ev msg)) ((equal? t "pong") #t) (else #t))))) ;; recreates a terminal view per live pty and reattaches it (scrollback). (when *on-sessions* (*on-sessions* sessions)))) (define (node-live-sessions) *live-sessions*) (define (node-list-sessions! ok err) (if (node-ready?) (request! "session/list" #{} ok err) (err "local node is not connected"))) ;; ---- inbound: ok / err / ev ---------------------------------------------- (define (on-ok msg) (bulk? (dict-ref msg bulk: #f)) (cb (dict-ref *req-cbs* id #f))) (cond ((and cb (dict-ref cb transfer: #f)) (resource-transfer-control! *resource-transfers* msg)) ;; bulk: the result body is the next 0x10 frame for this id. Park the ;; ok-callback until that frame lands. (bulk? (let* ((id (dict-ref msg id: #f)) (cb (dict-ref *req-cbs* id #f))) (when cb (if (dict-ref cb transfer: #f) (resource-transfer-control! *resource-transfers* msg) (begin (set! *req-cbs* (dict-set *req-cbs* id #f)) (set! *bulk-waiters* (dict-set *bulk-waiters* id #f)) (let ((f (dict-ref cb err: #f))) (when f (f #{ code: (dict-ref msg code: "error") message: (dict-ref msg message: "") })))))) message: (dict-ref msg message: "") })))))))) (define (on-ev msg) (when (equal? (dict-ref msg verb: #f) "agent/service-disconnected") (dict-keys *req-cbs*))) (let ((sub (dict-ref msg sub: #f))) (cond ((dict-ref *subscriptions* sub #f) ((dict-ref *subscriptions* sub #f) (dict-ref msg event: #{}))) ((equal? sub "session/exit") ;; CONTROL lane: this `ev` carries the durable STRING sid, so it ;; resolves rather than indexing. Named at the call site so the two ;; ---- inbound: binary frames ---------------------------------------------- (define (node-deliver-binary! bv) (let ((f (frame-decode bv))) (when f (let ((type (dict-ref f type: 0)) (id (dict-ref f id: 0)) (payload (dict-ref f payload: (make-bytevector 0 0)))) (cond ((= type FRAME-DATA-OUT) (on-data-out id payload)) ((= type FRAME-BULK) (on-bulk id payload)) ;; BINARY lane: `id` is the u32 chan out of the frame header, which ;; is what `*sessions*` is keyed by, so this indexes directly. ((= type FRAME-CLOSE) (deliver-exit-by-chan! id #f)) ((= type FRAME-ERROR) (on-frame-error id payload)) (else #t))))) (cond ((and (bytevector? bv) (> (bytevector-length bv) 0) (= (bytevector-u8-ref bv 0) #x11)) ;; A direct carrier is drained inside this node poll, but its nested ;; stream frame is decoded by enclave-node. Carry its terminal-data ;; result back into the same repaint latch used by native node PTYs. (when (and *on-remote-binary* (*on-remote-binary* bv)) (set! *drained-output* #t))) (else (let ((f (frame-decode bv))) (when f (let ((type (dict-ref f type: 0)) (id (dict-ref f id: 0)) (payload (dict-ref f payload: (make-bytevector 0 0)))) (cond ((and (dict-ref *req-cbs* id #f) (dict-ref (dict-ref *req-cbs* id #{}) transfer: #f) (resource-transfer-frame? bv)) (resource-transfer-frame! *resource-transfers* bv)) ((= type FRAME-DATA-OUT) (on-data-out id payload)) ((= type FRAME-BULK) (on-bulk id payload)) ;; The binary lane indexes sessions by its u32 channel. ((= type FRAME-CLOSE) (deliver-exit-by-chan! id #f)) ((= type FRAME-ERROR) (on-frame-error id payload)) (else #t))))))) "") ;; A bulk result frame: decode the sigil-wire payload and hand it to the (define (stat->entry st) #{ name: (dict-ref st name: "") kind: (wire-type->kind (dict-ref st type: "")) size: (dict-ref st size: 0) }) size: (dict-ref st size: #f) mtime: (dict-ref st mtime: #f) hidden: (dict-ref st hidden: (string-starts-with? (dict-ref st name: "") ".")) }) (define (node-materialize-async source on-ready on-error) (let ((kind (dict-ref source kind: file:)) (lambda (entries) (on-ready (map stat->entry (grants->list entries)))) (lambda (e) (on-error e))) ;; a file body decodes to a bytevector; the editor wants a string. (request! "fs/read-file" #{ path: addr } (request-transfer! "fs/read-file" #{ path: addr transfer: #t } 'download #f (lambda (bytes) (on-ready (if (bytevector? bytes) (utf8->string bytes) (or bytes "")))) (lambda (e) (on-error e)))))) ;; an error surface, matching the lantern provider. (define *on-persist-error* #f) (define (node-on-persist-error! f) (set! *on-persist-error* f) "") (define (node-on-transfer-progress! f) (set! *on-transfer-progress* f) "") (define (node-persist source value) (request! "fs/write-file" (let ((bytes (string->utf8 value))) ((if (>= (bytevector-length bytes) resource-transfer-threshold) (lambda (verb args ok err) (request-transfer! verb #{ path: (dict-ref args path: "") transfer: #t size: (bytevector-length bytes) } 'upload bytes ok err)) request!) "fs/write-file" #{ path: (dict-ref source address: "") content: value } #f (lambda (e) (when *on-persist-error* (*on-persist-error* (dict-ref source address: "") e)))) (*on-persist-error* (dict-ref source address: "") e))))) value) ;; Ask the node for its $HOME once, on ready, so the "This computer" root can ;; land dired at home (the daemon does NOT expand "~" in a path, and grants ;; are home-scoped, so an absolute home path is what dired needs). Uses ;; proc/run (an exec grant); on-home gets the trimmed path. The local: Lantern ;; root does the analogous fs.home fetch — this is its node twin. ;; are home-scoped, so an absolute home path is what dired needs). This is a ;; filesystem fact and uses fs/home, not proc/run: resource-only grants must ;; be sufficient to enter a node, and a shell probe made root discovery ;; depend on an unrelated execution grant. (define (node-fetch-home! on-home) (request! "proc/run" #{ argv: (list "sh" "-c" "printf %s \"$HOME\"") } (request! "fs/home" #{} (lambda (result) (let ((home (dict-ref result stdout: ""))) (let ((home (dict-ref result path: ""))) (when (and (string? home) (> (string-length home) 0)) (on-home home)))) (lambda (e) #f))) (lambda (e) (when on-err (on-err e)))) #t) (define (node-git-request source verb args on-ready on-error) (request! verb args on-ready on-error)) ;; ---- image serving: parity with the local: (lantern) provider ----------- ;; The image mode holds a loadable URL STRING and lets the browser <img> ;; decode it (see (slate modes image)); it resolves that URL through the ;; index.html (node-blob-poll / node-blob-bytes / node-blob-ready). (define (node-read-file-blob source on-ready on-error) (request! "fs/read-file" #{ path: (dict-ref source address: "") } (lambda (bytes) (if (bytevector? bytes) (begin (set! *blob-seq* (+ *blob-seq* 1)) (let ((token (string-append "b" (number->string *blob-seq*)))) (set! *blob-inflight* (dict-set *blob-inflight* token #{ on-ready: on-ready on-error: on-error })) (set! *blob-pending* (append *blob-pending* (list #{ token: token bytes: bytes }))))) (when on-error (on-error #{ code: "not-bytes" message: "fs/read-file did not return bytes" })))) (lambda (bytes) (node-queue-image! source bytes on-ready on-error)) (lambda (e) (when on-error (on-error e))))) ;; Shared binary handoff for local and Enclave transports. Keep bytes intact; ;; the browser decodes the image, including SVGs that require a MIME type. (define (node-queue-image! source bytes on-ready on-error) (if (bytevector? bytes) (begin (set! *blob-seq* (+ *blob-seq* 1)) (let* ((token (string-append "b" (number->string *blob-seq*))) (path (string-downcase (dict-ref source address: ""))) (mime (cond ((string-ends-with? path ".svg") "image/svg+xml") ((string-ends-with? path ".png") "image/png") ((or (string-ends-with? path ".jpg") (string-ends-with? path ".jpeg")) "image/jpeg") ((string-ends-with? path ".gif") "image/gif") ((string-ends-with? path ".webp") "image/webp") ((string-ends-with? path ".bmp") "image/bmp") (else "application/octet-stream")))) (set! *blob-inflight* (dict-set *blob-inflight* token #{ on-ready: on-ready on-error: on-error })) (set! *blob-pending* (append *blob-pending* (list #{ token: token bytes: bytes mime: mime }))))) (when on-error (on-error #{ code: "not-bytes" message: "fs/read-file did not return bytes" })))) (define (node-blob-mime) (if *blob-cur* (dict-ref *blob-cur* mime: "application/octet-stream") "application/octet-stream")) ;; JS drain (called each node tick, bounded): pop the next pending blob into ;; *blob-cur* and return its token (or "" if none). JS then reads the bytes via ;; node-blob-bytes and delivers the minted URL via node-blob-ready. (define (node-blob-ready token url) (let ((slot (dict-ref *blob-inflight* token #f))) (when slot (set! *blob-inflight* (dict-set *blob-inflight* token #f)) (set! *blob-inflight* (dict-remove *blob-inflight* token)) (when (and *blob-cur* (equal? token (dict-ref *blob-cur* token: #f))) (set! *blob-cur* #f)) (if (and (string? url) (> (string-length url) 0)) (let ((f (dict-ref slot on-ready: #f))) (when f (f url))) (let ((f (dict-ref slot on-error: #f))) (let ((emit (dict-ref ctx emit: #f)) (exit (dict-ref ctx exit: #f)) (err (dict-ref ctx err: #f)) (reattached (dict-ref ctx reattached: #f)) ;; slot 0 = daemon CHAN, the u32 the binary lane addresses ;; (#f until session/open resolves) ;; slot 1 = reversed list of buffered outbound byte frames (lambda (result) (let ((b (session-binding result))) (if b (correlate! (car b) (cdr b)) (begin ;; The ack precedes the ordered replay frame. Tell ;; the consumer whether it must reseed its retained ;; emulator before correlating and draining bytes. (when reattached (reattached (dict-ref result replay-bytes: #f) #f 'local)) (correlate! (car b) (cdr b))) (when err (err "session/attach reply did not carry a usable chan and sid"))))) (lambda (e) (when err (err e)))) (request! "session/open" (lambda (e) (when err (err e)))))) ;; the per-session op object the seam drives #{ write: (lambda (bytes) (write-bytes bytes)) remote-id: (lambda () (vector-ref st 2)) resize: (lambda (cols rows) (let ((p (make-bytevector 4 0))) (write-u16-be! p 0 cols) (write-u16-be! p 2 rows) (define (node-open-socket!) (let ((conn (guard (e (#t #f)) (ws-connect *url*)))) (set! *transport-generation* (+ *transport-generation* 1)) (set! *conn* conn) (set! *saw-open* #f) (set! *handshake-ticks* 0) ;; Connect with an INJECTED transport (tests): capture outbound frames and ;; feed inbound via node-deliver-*!, no real socket. (define (node-connect-with! transport) (set! *transport-generation* (+ *transport-generation* 1)) (set! *transport* transport) (set! *conn* 'injected) (set! *phase* 'connecting) #t) (define (node-disconnect!) (when (and *transport* (dict-ref *transport* close: #f)) ((dict-ref *transport* close: #f))) (set! *conn* #f) (set! *transport* #f) (set! *available* #f) (set! *phase* 'closed)) (let ((was-live (or *conn* *transport*))) (when (and *transport* (dict-ref *transport* close: #f)) ((dict-ref *transport* close: #f))) (set! *conn* #f) (set! *transport* #f) (set! *available* #f) (set! *phase* 'closed) (when (and was-live *on-remote-disconnect*) (*on-remote-disconnect* *transport-generation*)))) (define (schedule-reconnect!) (set! *reconnect-in* *backoff-ticks*) ;; exponential backoff: double each attempt, cap ~30s (200 ticks @150ms). (set! *backoff-ticks* (min 200 (* *backoff-ticks* 2)))) (define (node-deliver-disconnect! generation) (resource-transfer-remove-owner! *resource-transfers* generation "node connection lost") (when (and (= generation *transport-generation*) (or *conn* *transport*)) (set! *conn* #f) (set! *transport* #f) (set! *available* #f) (set! *phase* 'closed) (when *on-remote-disconnect* (*on-remote-disconnect* generation)) (when *on-close* (*on-close*)) (when *url* (schedule-reconnect!))) "") (define (on-socket-closed!) (set! *conn* #f) (set! *transport* #f) (set! *available* #f) (set! *phase* 'closed) (set! *handshake-ticks* 0) (when *on-close* (*on-close*)) (when *url* (schedule-reconnect!))) (node-deliver-disconnect! *transport-generation*)) ;; The tick: drain ready frames, advance reconnect. Called ~every 16ms by the ;; JS metronome (slate-node-tick) and directly by tests. Resets the ;; Install a capturing transport (tests). Returns the transport dict. (define (node-set-transport! . maybe) (let ((tr (if (null? maybe) (capture-transport) (car maybe)))) (set! *transport-generation* (+ *transport-generation* 1)) (set! *transport* tr) (set! *conn* 'injected) tr)) (define (node-clear-sent!) (set! *sent-text* '()) (set! *sent-binary* '()) "") (define (node-reset!) (resource-transfer-remove-owner! *resource-transfers* *transport-generation* "node provider reset") (set! *available* #f) (set! *phase* 'idle) (set! *url* #f) (set! *instance* #f) (set! *grants* '()) (set! *token* #f) (set! *conn* #f) (set! *saw-open* #f) (set! *drained-output* #f) (set! *next-id* 0) (set! *req-cbs* #{}) (set! *bulk-waiters* #{}) (set! *sessions* #{}) (set! *subscriptions* #{}) (set! *blob-seq* 0) (set! *blob-pending* '()) (set! *blob-inflight* #{}) (set! *blob-cur* #f) (set! *image-via-blob?* #f) (set! *transport* #f) (set! *on-sessions* #f) (set! *on-ready* #f) (set! *on-pair-needed* #f) (set! *on-close* #f) (set! *on-token* #f) (set! *on-persist-error* #f) (set! *on-transfer-progress* #f) (set! *on-remote-control* #f) (set! *on-remote-binary* #f) (set! *on-remote-disconnect* #f) (set! *transport-generation* 0) (set! *sent-text* '()) (set! *sent-binary* '()) "") (node-rename source target on-ready on-error)) delete: (lambda (source on-ready on-error) (node-delete source on-ready on-error)) subscribe: (lambda (source cb) (lambda () 'unsubscribed)) subscribe: (lambda (source cb)Showing the first 500 of 514 diff lines for this file. This diff is INCOMPLETE; read the file or clone the repository for the rest.
packages/slate/src/slate/providers/slate-meta.sglmodified
;; extra key rides along untouched until child-source reads it back. (define (root-entries) (map (lambda (n) #{ name: (root-name-string n) kind: dir: root: n }) (filter (lambda (n) (not (eq? n slate:))) (root-names)))) (filter (lambda (n) (not (eq? n slate:))) (root-visible-names)))) ;; The top level: doorway directory + content tenants. `welcome` and ;; `scratchpad` are COMPOSED views, not byte files: (slate main)'spackages/slate/src/slate/resource/transfer.sgladded
;;; (slate resource transfer) — renderer-side credited Resource transfers.(define-library (slate resource transfer) (import (sigil core) (sigil math)) (export resource-transfer-threshold resource-transfer-chunk-bytes make-resource-transfer-registry resource-transfer-count resource-transfer-upload! resource-transfer-download! resource-transfer-control! resource-transfer-frame! resource-transfer-cancel! resource-transfer-remove-owner! resource-transfer-frame? transfer-frame-encode transfer-frame-decode) (begin (define resource-transfer-threshold 3145728) (define resource-transfer-chunk-bytes 65536) (define initial-credit (* resource-transfer-chunk-bytes 4)) (define frame-batch #x20) (define frame-end #x21) (define frame-cancel #x22) (define frame-transfer-credit #x23) (define (put-u32! bytes at n) (bytevector-u8-set! bytes at (modulo (quotient n 16777216) 256)) (bytevector-u8-set! bytes (+ at 1) (modulo (quotient n 65536) 256)) (bytevector-u8-set! bytes (+ at 2) (modulo (quotient n 256) 256)) (bytevector-u8-set! bytes (+ at 3) (modulo n 256))) (define (get-u32 bytes at) (+ (* (bytevector-u8-ref bytes at) 16777216) (* (bytevector-u8-ref bytes (+ at 1)) 65536) (* (bytevector-u8-ref bytes (+ at 2)) 256) (bytevector-u8-ref bytes (+ at 3)))) (define (transfer-frame-encode type id payload) (let* ((n (bytevector-length payload)) (out (make-bytevector (+ n 5) 0))) (bytevector-u8-set! out 0 type) (put-u32! out 1 id) (when (> n 0) (bytevector-copy! out 5 payload 0 n)) out)) (define (transfer-frame-decode bytes) (and (bytevector? bytes) (>= (bytevector-length bytes) 5) #{ type: (bytevector-u8-ref bytes 0) id: (get-u32 bytes 1) payload: (bytevector-copy bytes 5 (bytevector-length bytes)) })) (define (resource-transfer-frame? bytes) (let ((f (transfer-frame-decode bytes))) (and f (member (dict-ref f type: 0) (list frame-batch frame-end frame-cancel frame-transfer-credit))))) (define (batch-frame id seq bytes) (let* ((n (bytevector-length bytes)) (p (make-bytevector (+ n 4) 0))) (put-u32! p 0 seq) (bytevector-copy! p 4 bytes 0 n) (transfer-frame-encode frame-batch id p))) (define (end-frame id seq total) (let ((p (make-bytevector 8 0))) (put-u32! p 0 seq) (put-u32! p 4 total) (transfer-frame-encode frame-end id p))) (define (credit-frame id amount) (let ((p (make-bytevector 4 0))) (put-u32! p 0 amount) (transfer-frame-encode frame-transfer-credit id p))) (define (make-resource-transfer-registry) (vector '())) (define (entries r) (vector-ref r 0)) (define (resource-transfer-count r) (length (entries r))) (define (find-entry r id) (let loop ((xs (entries r))) (and (pair? xs) (if (= id (dict-ref (car xs) id: -1)) (car xs) (loop (cdr xs)))))) (define (replace! r id value) (vector-set! r 0 (map (lambda (e) (if (= id (dict-ref e id: -1)) value e)) (entries r))) value) (define (remove! r id) (let ((old (find-entry r id))) (vector-set! r 0 (filter (lambda (e) (not (= id (dict-ref e id: -1)))) (entries r))) old)) (define (emit-progress! entry state completed) (let ((f (dict-ref entry progress: #f))) (when f (f #{ id: (dict-ref entry id: 0) direction: (dict-ref entry direction: 'unknown) completed: completed total: (dict-ref entry total: 0) outstanding: (transfer-outstanding entry) peak-outstanding: (dict-ref entry peak-outstanding: 0) peak-frame-bytes: (dict-ref entry peak-frame-bytes: 0) transport: (dict-ref entry transport: #f) state: state })))) (define (transfer-outstanding entry) (if (eq? (dict-ref entry direction: #f) 'upload) (- (dict-ref entry sent: 0) (dict-ref entry acknowledged: 0)) (- (dict-ref entry granted: 0) (dict-ref entry received: 0)))) (define (with-high-water entry frame-bytes) (let ((outstanding (transfer-outstanding entry))) (dict-set (dict-set entry peak-outstanding: (max outstanding (dict-ref entry peak-outstanding: 0))) peak-frame-bytes: (max frame-bytes (dict-ref entry peak-frame-bytes: 0))))) (define (fail! r entry reason) (remove! r (dict-ref entry id: 0)) (emit-progress! entry 'failed (if (eq? (dict-ref entry direction: #f) 'download) (dict-ref entry received: 0) (dict-ref entry acknowledged: 0))) (let ((f (dict-ref entry error: #f))) (when f (f reason))) #f) (define (register! r entry) (if (find-entry r (dict-ref entry id: -1)) #f (begin (vector-set! r 0 (cons entry (entries r))) entry))) (define (resource-transfer-upload! r id owner bytes transport send complete error progress) (and (bytevector? bytes) (register! r #{ id: id owner: owner direction: 'upload phase: 'negotiating bytes: bytes total: (bytevector-length bytes) sent: 0 acknowledged: 0 credit: 0 seq: 0 end-sent?: #f peak-outstanding: 0 peak-frame-bytes: 0 transport: transport send: send complete: complete error: error progress: progress }))) (define (resource-transfer-download! r id owner transport send complete error progress) (register! r #{ id: id owner: owner direction: 'download phase: 'negotiating total: 0 received: 0 granted: 0 credit: 0 seq: 0 destination: #f peak-outstanding: 0 peak-frame-bytes: 0 transport: transport send: send complete: complete error: error progress: progress })) (define (send-upload! r entry) (let loop ((e entry)) (let ((credit (dict-ref e credit: 0)) (sent (dict-ref e sent: 0)) (total (dict-ref e total: 0))) (cond ((and (= sent total) (not (dict-ref e end-sent?: #f))) ((dict-ref e send: #f) (end-frame (dict-ref e id: 0) (dict-ref e seq: 0) total)) (replace! r (dict-ref e id: 0) (dict-set (dict-set e end-sent?: #t) phase: 'awaiting-final))) ((or (= credit 0) (= sent total)) e) (else (let* ((remaining (- total sent)) (n (cond ((< credit resource-transfer-chunk-bytes) (if (< credit remaining) credit remaining)) ((< resource-transfer-chunk-bytes remaining) resource-transfer-chunk-bytes) (else remaining))) (chunk (bytevector-copy (dict-ref e bytes: #f) sent (+ sent n))) (seq (dict-ref e seq: 0))) ((dict-ref e send: #f) (batch-frame (dict-ref e id: 0) seq chunk)) (let ((next (with-high-water (dict-set (dict-set (dict-set e sent: (+ sent n)) credit: (- credit n)) seq: (+ seq 1)) n))) (replace! r (dict-ref e id: 0) next) (loop next)))))))) (define (resource-transfer-control! r message) (let* ((id (dict-ref message id: #f)) (entry (and (integer? id) (find-entry r id)))) (when entry (cond ((equal? (dict-ref message t: #f) "err") (fail! r entry (dict-ref message message: "transfer failed"))) ((and (equal? (dict-ref message t: #f) "ok") (dict-ref message transfer: #f) (eq? (dict-ref entry phase: #f) 'negotiating)) (let* ((info (dict-ref message transfer: #{}) ) (direction (dict-ref info direction: "")) (total (dict-ref info size: #f))) (cond ((and (eq? (dict-ref entry direction: #f) 'upload) (equal? direction "upload") (integer? total) (= total (dict-ref entry total: -1))) (replace! r id (dict-set entry phase: 'active))) ((and (eq? (dict-ref entry direction: #f) 'download) (equal? direction "download") (integer? total) (>= total 0)) (let ((next (dict-set (dict-set (dict-set entry total: total) destination: (make-bytevector total 0)) phase: 'active))) (set! next (with-high-water (dict-set next granted: initial-credit) 0)) (replace! r id next) ((dict-ref next send: #f) (credit-frame id initial-credit)))) (else (fail! r entry "invalid transfer negotiation"))))) ((and (equal? (dict-ref message t: #f) "ok") (eq? (dict-ref entry direction: #f) 'upload) (eq? (dict-ref entry phase: #f) 'awaiting-final)) (remove! r id) (emit-progress! entry 'complete (dict-ref entry total: 0)) (let ((f (dict-ref entry complete: #f))) (when f (f (dict-ref message result: #{}))))) (else #f))))) (define (handle-credit! r entry payload) (if (not (= (bytevector-length payload) 4)) (fail! r entry "malformed transfer credit") (let* ((amount (get-u32 payload 0)) (unacked (- (dict-ref entry sent: 0) (dict-ref entry acknowledged: 0))) (acked (+ (dict-ref entry acknowledged: 0) (if (< amount unacked) amount unacked))) (next (dict-set (dict-set entry acknowledged: acked) credit: (+ (dict-ref entry credit: 0) amount)))) (replace! r (dict-ref entry id: 0) next) (emit-progress! next 'active acked) (send-upload! r next)))) (define (handle-download-batch! r entry payload) (if (< (bytevector-length payload) 4) (fail! r entry "malformed transfer batch") (let* ((seq (get-u32 payload 0)) (bytes (bytevector-copy payload 4 (bytevector-length payload))) (n (bytevector-length bytes)) (at (dict-ref entry received: 0))) (if (or (not (= seq (dict-ref entry seq: 0))) (> (+ at n) (dict-ref entry total: 0))) (fail! r entry "invalid transfer sequence or size") (begin (bytevector-copy! (dict-ref entry destination: #f) at bytes 0 n) (let* ((credit (min n (max 0 (- (dict-ref entry total: 0) (dict-ref entry granted: 0))))) (next (with-high-water (dict-set (dict-set (dict-set entry received: (+ at n)) granted: (+ (dict-ref entry granted: 0) credit)) seq: (+ seq 1)) n))) (replace! r (dict-ref entry id: 0) next) (emit-progress! next 'active (+ at n)) ;; Once all remaining bytes are credited the sender may ;; finish and close. Do not write more credit into that ;; completed stream while receiving its final window. (when (> credit 0) ((dict-ref next send: #f) (credit-frame (dict-ref next id: 0) credit))))))))) (define (handle-download-end! r entry payload) (if (not (= (bytevector-length payload) 8)) (fail! r entry "malformed transfer end") (let ((seq (get-u32 payload 0)) (total (get-u32 payload 4))) (if (or (not (= seq (dict-ref entry seq: 0))) (not (= total (dict-ref entry total: 0))) (not (= total (dict-ref entry received: 0)))) (fail! r entry "incomplete transfer") (begin (remove! r (dict-ref entry id: 0)) (emit-progress! entry 'complete total) (let ((f (dict-ref entry complete: #f))) (when f (f (dict-ref entry destination: #f))))))))) (define (resource-transfer-frame! r bytes) (let* ((frame (transfer-frame-decode bytes)) (entry (and frame (find-entry r (dict-ref frame id: -1))))) (and entry (let ((type (dict-ref frame type: 0)) (payload (dict-ref frame payload: #f))) (cond ((and (= type frame-transfer-credit) (eq? (dict-ref entry direction: #f) 'upload)) (handle-credit! r entry payload)) ((and (= type frame-batch) (eq? (dict-ref entry direction: #f) 'download)) (handle-download-batch! r entry payload)) ((and (= type frame-end) (eq? (dict-ref entry direction: #f) 'download)) (handle-download-end! r entry payload)) ((= type frame-cancel) (fail! r entry "peer cancelled transfer")) (else (fail! r entry "unexpected transfer frame"))))))) (define (resource-transfer-cancel! r id reason) (let ((entry (find-entry r id))) (and entry (begin ((dict-ref entry send: #f) (transfer-frame-encode frame-cancel id (make-bytevector 0))) (fail! r entry reason))))) (define (resource-transfer-remove-owner! r owner reason) (let ((ids (map (lambda (e) (dict-ref e id: 0)) (filter (lambda (e) (equal? owner (dict-ref e owner: #f))) (entries r))))) (for-each (lambda (id) (let ((e (find-entry r id))) (when e (fail! r e reason)))) ids) (length ids))) ))packages/slate/src/slate/root.sglmodified
; two surfaces cannot drift apart (slate provider)) (export ;; registry register-root! register-root-silent! unregister-root! root-ref root-registered? root-names root-set! root-named register-root! register-root-silent! unregister-root! root-ref root-registered? root-names root-visible-names root-set! root-named set-root-registered-handler! notify-root-registered! ;; metadata accessors root-home root-name-string root-annotation root-home root-name-string root-annotation canonical-root-name ;; R3a: ruling 4's `unknown:` state, and ruling 6's age on it ;; R3b: `root-mark-unknown!` — the per-root flip a failed per-node ;; re-attach fires (the dot's second source of `unknown:`) (let ((e (assoc name *roots*))) (and e (cdr e)))) (define (root-registered? name) (and (root-ref name) #t)) (define (root-names) (map car *roots*)) ;; Internal aliases (notably `local:`) may remain registered so persisted ;; sources and provider dispatch stay compatible while a canonical enrolled ;; machine root is what navigation surfaces show. (define (root-visible-names) (filter (lambda (name) (not (dict-ref (root-ref name) hidden?: #f))) (root-names))) ;; Update one field of a registered root in place — the async fills (a ;; machine's `home:` landing from its fs.home RPC, presence flipping (define (root-home name) (let ((r (root-ref name))) (and r (dict-ref r home: #f)))) ;; `local:` is accepted forever at input/provider boundaries, but once an ;; enrolled (or otherwise canonically named) root identifies this machine, ;; every identity we WRITE uses that root. There may be only one local ;; machine in a client; the first distinct local root is therefore the ;; canonical target. With no such root, pure-local Slate keeps `local:`. (define (canonical-root-name name) (if (not (eq? name local:)) name (let loop ((names (root-names))) (cond ((null? names) local:) ((and (not (eq? (car names) local:)) (dict-ref (root-ref (car names)) local?: #f)) (car names)) (else (loop (cdr names))))))) ;; The registered root whose name prints as `s` ("browser" -> browser:), or #f. ;; For action strings ("openroot:browser") — the reverse of root-name-string ;; without needing a string->keyword primitive. "" (let* ((class (dict-ref r class: #f)) (status (dict-ref r status: #f)) (local? (dict-ref r local?: #f)) (age (root-age-phrase r))) (string-append (if local? "local · " "") (cond ((and class status) (string-append (keyword->string class) " · " (keyword->string status))) (cond ((null? es) #f) ((and (eq? (dict-ref (cdr (car es)) provider: #f) prov) (equal? (dict-ref (cdr (car es)) host: #f) host)) (car (car es))) (canonical-root-name (car (car es)))) (else (loop (cdr es))))))) ;; Root-aware display name: "<root>:<addr>", except a root registered with (else (rooted-path name addr))))) ;; ---- the path grammar ---------------------------------------------------- ;; The full rooted spelling ("zerocool:/etc") — parse-rooted-path's inverse, ;; and the ONE place the spelling is defined (labels, display names and cache ;; keys all call this, so they can never drift apart). Display policy ;; The full rooted spelling ("zerocool:/etc") and the ONE write-time ;; canonicalization point (labels, persisted addresses and cache keys all call ;; this, so they can never drift apart). `local:` remains parseable, but when ;; a canonical local machine is known it is never emitted. Display policy ;; (local:'s bareness) is source-root-display-name's business, not this; the ;; rooted spelling is the SWITCHING gesture and is always explicit. (define (rooted-path name path) (string-append (root-name-string name) ":" path)) (string-append (root-name-string (canonical-root-name name)) ":" path)) ;; "zerocool:/etc" -> #{ root: zerocool: path: "/etc" } when "zerocool" names ;; a registered root AND the remainder is absolute; #f otherwise. Matching ispackages/slate/src/slate/selector.sglmodified
;; PEEK that must not steal focus — the frame keeps it so the NEXT chord key ;; still reaches the on-key: handler. That is the whole point David specified: ;; `?` must not break the chord. (define (render-which-key title km) (let ((keys (msort key-before? (dict-keys km)))) (define (render-which-key title km . rest) ;; Callers can supply human-readable labels to make a command menu ;; tappable through the same canonical key path as the accessory bar. (let ((keys (msort key-before? (dict-keys km))) (labels (if (pair? rest) (car rest) #{}))) `(div (@ (class "which-key-scrim")) (div (@ (class "which-key-panel")) (div (@ (class "wk-title")) (span (@ (class "wk-hint")) "press a key · ? for this list · esc to cancel")) (div (@ (class "wk-grid")) ,@(map (lambda (k) (let ((v (dict-ref km k #f))) `(div (@ (class ,(if (dict? v) "wk-row wk-row-prefix" "wk-row"))) (let ((v (dict-ref km k #f)) (label (dict-ref labels k #f))) `(,(if label 'button 'div) (@ (class ,(if (dict? v) "wk-row wk-row-prefix" "wk-row")) ,@(if label `((data-slate-key ,k)) '())) (span (@ (class "wk-key")) ,k) (span (@ (class "wk-arrow")) "→") (span (@ (class "wk-cmd")) ,(if (dict? v) "▸ prefix" v))))) ,(or label (if (dict? v) "▸ prefix" v)))))) keys)))))) ;; Order the panel rows: plain keys before prefixed (C-/M-) ones, then by thepackages/slate/src/slate/state.sglmodified
(update-view state "*messages*" (lambda (m) (view-add-line m msg))))) (define (echo-transient state msg) (dict-set (dict-set (dict-set state slate/echo: msg) slate/echo-age: 0) slate/echo-fading?: #f)) (dict-set (dict-set (dict-set (dict-set state slate/echo: msg) slate/echo-age: 0) slate/echo-fading?: #f) slate/echo-generation: (+ 1 (dict-ref state slate/echo-generation: 0)))) ;; Show a message in the echo area and tee it into *messages* immediately. (define (with-echo state msg)packages/slate/src/slate/theme.sglmodified
;; Non-string values (e.g. the `faces:` sub-dict, which (slate face) lowers ;; separately into the faces <style>) are skipped — they are not palette vars. (define (theme->css-vars theme) (string-join (map (lambda (p) (let ((k (car p)) (v (cdr p))) (cond ((eq? k 'scheme) (string-append "color-scheme:" v ";")) ((string? v) (string-append "--" (symbol->string k) ":" v ";")) (else "")))) (dict->alist theme)) "")))) (string-append ;; A theme-derived surface specifically for the phone keyboard deck. Dark ;; themes use their deepest content background; light themes use their ;; darkest background step, keeping the deck below the Ledge visually. "--key-deck-bg:" (dict-ref theme (if (string=? (dict-ref theme scheme: "dark") "light") bg-3: bg-0:) "#08090a") ";" (string-join (map (lambda (p) (let ((k (car p)) (v (cdr p))) (cond ((eq? k 'scheme) (string-append "color-scheme:" v ";")) ((string? v) (string-append "--" (symbol->string k) ":" v ";")) (else "")))) (dict->alist theme)) "")))))packages/slate/src/slate/tmux/ladder.sglmodified
;;; them to ignore the line that matters in rows 2 and 3.(define-library (slate tmux ladder) (import (sigil core)) (import (sigil core) (slate root)) (export tmux-ladder ladder-backing ladder-downgraded? ladder-reason ;; reading a node's answer ;; ================================================================ ;; How to NAME a node in a degrade sentence. "" is the local node — the same ;; convention every remote verb uses, where an absent `host:` means the ;; directly-attached daemon — and an empty string would read as a hole in ;; the middle of a sentence. ;; How to NAME a node in a degrade sentence. Discovery deliberately keeps ;; the owner-facing root label separate from the opaque transport nick, so ;; resolve the latter back through the registry before it reaches UI copy. ;; Unknown hosts retain the legacy fallback and "" remains the local node. (define (tmux-node-label host) (if (and (string? host) (> (string-length host) 0)) host "this computer")) (if (and (string? host) (> (string-length host) 0)) (let loop ((names (root-names))) (if (null? names) host (let* ((name (car names)) (root (root-ref name))) (if (and root (equal? (dict-ref root host: #f) host)) (dict-ref root label: (root-name-string name)) (loop (cdr names)))))) "this computer")) ;; Pull the `tmux:` capability out of a `sys/info` reply. ;;packages/slate/src/slate/ui/diff.sglmodified
;;; Unified diff presentation shared by Slate views. Legacy patches without;;; numbered hunks remain readable, but never acquire invented line numbers.(define-library (slate ui diff) (import (sigil string)) (import (sigil string) (slate components diff)) (export diff-rows diff-statistics diff-render) (begin (define (range-start token sign) (define (diff-render text) `(div (@ (class "agent-diff") (tabindex "0") (role "region") (aria-label "Unified diff")) ,@(map (lambda (row) (let ((kind (dict-ref row kind:))) (if (member kind '("hunk" "meta")) `(div (@ (class ,(string-append "agent-diff-" kind))) ,(dict-ref row text:)) `(div (@ (class ,(string-append "agent-diff-line agent-diff-" kind))) (span (@ (class "agent-line-number") (aria-hidden "true")) ,(if (dict-ref row old:) (number->string (dict-ref row old:)) "")) (span (@ (class "agent-line-number") (aria-hidden "true")) ,(if (dict-ref row new:) (number->string (dict-ref row new:)) "")) (span (@ (class "agent-line-sign") (aria-hidden "true")) ,(dict-ref row sign:)) (code ,(dict-ref row text:)))))) (diff-rows text)))))) ,(diff-control-render (diff-normalize-unified text) #f #f))) ))packages/slate/test/agent-session-test.sglmodified
diff: "--- a/src/main.sgl\n+++ b/src/main.sgl\n@@\n-old\n+new\n" })) (text (format "~s" node))) (assert-true (string-contains? text "details")) (assert-true (string-contains? text "agent-diff-remove")) (assert-true (string-contains? text "agent-diff-add")) (assert-true (string-contains? text "slate-diff-deletion")) (assert-true (string-contains? text "slate-diff-addition")) (assert-true (string-contains? text "slate-diff-old")) (assert-true (string-contains? text "slate-diff-new")) (assert-true (string-contains? text "src/main.sgl"))))(test "file changes without bodies retain the compact fallback" (rendered (format "~s" (force-memo-content (agent-session-render ended))))) (assert-equal (- (length (string-split rendered "agent-open-file:src/main.sgl")) 1) 2) (assert-true (string-contains? rendered "agent-tool-change")) (assert-true (string-contains? rendered "agent-diff-add")) (assert-true (string-contains? rendered "slate-diff-addition")) (assert-false (string-contains? rendered "class \"agent-file-change\""))))(test "message updates render partial text before completion and final replaces it"packages/slate/test/agent-session-ui-browser-gate.mjsmodified
await push('tool-execution-start', '#{ tool: "edit" tool-execution-id: "edit-ui" arguments: #{ path: "src/session.sgl" } }'); await push('file-change', '#{ tool-execution-id: "edit-ui" operation: "edit" path: "src/session.sgl" diff: "--- a/src/session.sgl\n+++ b/src/session.sgl\n@@ -8,1 +8,2 @@\n-old\n+new\n+extra\n" }'); await push('tool-execution-end', '#{ tool-execution-id: "edit-ui" result: #{ ok: #t } }'); assert.equal(await evaluate('document.querySelectorAll(".agent-tool-change .agent-diff-add").length'), 2); assert.equal(await evaluate('document.querySelectorAll(".agent-tool-change .slate-diff-addition").length'), 2); assert.equal(await evaluate('document.querySelector(".agent-tool-change .agent-open-file").getAttribute("data-slate-click")'),'agent-open-file:src/session.sgl'); assert.equal(await evaluate('document.querySelector(".agent-review-file .agent-open-file").getAttribute("data-slate-click")'),'agent-open-file:src/session.sgl'); await evaluate('document.querySelector(".agent-session-head [data-agent-ui=review]").click();window.uiReview=document.querySelector(".agent-change-review");window.uiFile=uiReview.querySelector(".agent-review-file");uiFile.open=false');packages/slate/test/appearance-command-test.sglmodified
(define exact-events (list "flip-thumb-side" "theme:toggle" "effects:toggle" "ffm-toggle" "translucency:toggle" "terminals-use-tmux:toggle" "translucency:toggle" "terminals-use-tmux:toggle" "terminal-predictive-echo:toggle" "opacity:dec" "opacity:inc" "chrome-opacity:dec" "chrome-opacity:inc" "text-size:dec" "text-size:inc")) (list "theme:set:slate-light" "opacity:set:72" "chrome-opacity:set:90" "text-size:noop")))(test "predictive echo defaults off and toggles both ways" (let* ((on (command-run base "terminal-predictive-echo:toggle")) (off (command-run on "terminal-predictive-echo:toggle"))) (assert-true (dict-ref on slate/terminal-predictive-echo: #f)) (assert-false (dict-ref off slate/terminal-predictive-echo: #t))))(test "theme commands preserve state, prompt-close, and echo behavior" (let* ((open (dict-set base slate/minibuffer: #{})) (toggled (command-run open "theme:toggle"))packages/slate/test/client-update-policy-test.mjsadded
import assert from 'node:assert/strict';import fs from 'node:fs';const sw = fs.readFileSync(new URL('../assets/sw.js', import.meta.url), 'utf8');const html = fs.readFileSync(new URL('../assets/index.html', import.meta.url), 'utf8');// Node Status must identify the bytes actually executing, not an independently// maintained historical label. Every client release gives the document and its// cache-owning worker the same version.const workerVersion = sw.match(/const VERSION = '([^']+)'/)?.[1];const clientVersion = html.match(/const SLATE_WEB_CLIENT_VERSION = '([^']+)'/)?.[1];assert.ok(workerVersion, 'service worker version is missing');assert.equal(clientVersion, workerVersion, 'document and service worker versions diverged');// A replacement may be fetched and installed in the background, but the sole// activation authority is the explicit page -> waiting-worker message.assert.equal((sw.match(/self\.skipWaiting\(\)/g) || []).length, 1);assert.match(sw, /data\.type === 'skip-waiting'\) self\.skipWaiting\(\)/);assert.doesNotMatch(sw, /clients\.claim\(/);// The command activates in place and lets controllerchange perform one reload.// It must not use the old unregister -> reload -> claim -> reload sequence.assert.match(html, /if \(!window\.__slateClientUpdateReloadArmed\) return;/);assert.match(html, /window\.__slateClientUpdateReloadArmed = true;\s*worker\.postMessage\(\{ type: 'skip-waiting' \}\);/);assert.doesNotMatch(html, /\.unregister\(\)/);assert.doesNotMatch(html, /searchParams\.set\('slate-update'/);// Existing and newly installed waiting workers raise the same in-app notice.// Detection itself never arms activation; the action uses the explicit command.assert.match(html, /if \(registration\.waiting\) announceWaitingClient\(registration\.waiting\);/);assert.match(html, /installing\.state === 'installed'.*navigator\.serviceWorker\.controller \|\| registration\.active/s);assert.match(html, /slate-client-update-available/);assert.match(html, /'registration-waiting-state'/);console.log('client update policy: notification, manual activation, and one reload');packages/slate/test/diff-control-test.sgladded
;;; Shared embedded diff normalization and presentation.(import (sigil core) (sigil string) (sigil test) (slate components diff) (slate modes agent-session))(define ROWS (diff-normalize-unified "@@ -10,2 +20,2 @@\n same\n-old\n+new"))(test "memoized diff construction is lazy and selection changes only affected groups" (let* ((rows (let loop ((i 0) (out '())) (if (= i 70) (reverse out) (loop (+ i 1) (cons #{ id: (number->string i) kind: addition: text: "+line" } out))))) (prepared 0) (prepare (lambda (row) (set! prepared (+ prepared 1)) row)) (before (cddr (diff-control-render rows "31" #f revision: 1 prepare-row: prepare))) (after (cddr (diff-control-render rows "32" #f revision: 1 prepare-row: prepare))) (changed (cddr (diff-control-render rows "32" #f revision: 2 prepare-row: prepare)))) (assert-equal 0 prepared) ;; The lazy boundary carries key, revision and producer in its descriptor. (assert-false (equal? (vector-ref (car before) 2) (vector-ref (car after) 2))) (assert-false (equal? (vector-ref (cadr before) 2) (vector-ref (cadr after) 2))) (assert-equal (vector-ref (caddr before) 2) (vector-ref (caddr after) 2)) (assert-false (equal? (vector-ref (caddr after) 2) (vector-ref (caddr changed) 2))) ((vector-ref (car before) 3)) (assert-equal 32 prepared)))(test "unified adapter derives editor line-number coordinates" (assert-equal hunk: (dict-ref (list-ref ROWS 0) kind: #f)) (assert-equal 10 (dict-ref (list-ref ROWS 1) old-line: #f)) (assert-equal 20 (dict-ref (list-ref ROWS 1) new-line: #f)) (assert-equal 11 (dict-ref (list-ref ROWS 2) old-line: #f)) (assert-equal #f (dict-ref (list-ref ROWS 2) new-line: #f)) (assert-equal #f (dict-ref (list-ref ROWS 3) old-line: #f)) (assert-equal 21 (dict-ref (list-ref ROWS 3) new-line: #f)))(test "shared control renders semantic color classes and copy-safe gutters" (let ((rendered (format "~s" (diff-control-render ROWS #f #f)))) (assert-true (string-contains? rendered "slate-diff-addition")) (assert-true (string-contains? rendered "slate-diff-deletion")) (assert-true (string-contains? rendered "slate-diff-hunk")) (assert-true (string-contains? rendered "aria-hidden")) (assert-true (string-contains? rendered "slate-diff-code"))))(test "structured Git rows use the same host-independent renderer" (let ((node (diff-row-render #{ kind: addition: text: "+added" old-line: #f new-line: 7 } #t #f))) (assert-true (string-contains? (format "~s" node) "slate-diff-addition selected"))))(test "only expandable diff rows render disclosure actions" (let ((line (format "~s" (diff-row-render #{ kind: addition: text: "+added" } #f "select-line"))) (hunk (format "~s" (diff-row-render #{ kind: hunk: text: "@@ -1 +1 @@" } #f "toggle-hunk")))) (assert-true (string-contains? line "data-slate-click")) (assert-false (string-contains? line "slate-diff-action")) (assert-true (string-contains? hunk "slate-diff-hunk-toggle"))))(test "dedicated change marker is not duplicated in diff text" (let ((added (format "~s" (diff-row-render #{ kind: addition: text: "+added" } #f #f))) (removed (format "~s" (diff-row-render #{ kind: deletion: text: "-removed" } #f #f)))) (assert-false (string-contains? added "\"+added\"")) (assert-false (string-contains? removed "\"-removed\"")) (assert-true (string-contains? added "\"added\"")) (assert-true (string-contains? removed "\"removed\""))))(test "agent file changes normalize through the shared diff control" (let ((rendered (format "~s" (agent-file-change-node #{ operation: "edit" path: "a.sgl" diff: "@@ -3 +4 @@\n-old\n+new" })))) (assert-true (string-contains? rendered "slate-diff-control")) (assert-true (string-contains? rendered "slate-diff-old")) (assert-true (string-contains? rendered "slate-diff-addition"))))packages/slate/test/enclave-account-session-test.sglmodified
(account-session-set-transport! (mock-transport)))(test-group "Enclave account session" (test "account integration installs the node message handler without calling it" (reset-fixture!) (let ((state #{}) (messages 0) (registered #f)) (configure-account-session! #{ state: (lambda () state) set-state: (lambda (next) (set! state next)) refresh: (lambda () #t) recoverable?: (lambda () #f) node-message: (lambda (message) (set! messages (+ messages 1))) node-persist-error: (lambda (callback) #t) node-register: (lambda (refresh) (set! registered #t)) pairing-ready: (lambda () #t) pairing-error: (lambda (reason) #t) }) (account-on-ready!) (assert-equal 0 messages) (assert-true registered))) (test "PAIR persists once, reconnects, proves SASL, and reaches ready" (reset-fixture!) (let* ((credential (make-string 64 #\c))packages/slate/test/enclave-node-carrier-resource-test.sgladded
(import (sigil core) (sigil test) (sigil json) (sigil wire) (slate provider) (slate root) (slate providers node) (slate providers enclave-node) (slate providers enclave-node carrier))(define (ready-loopback!) (node-reset!) (node-set-transport!) (node-set-token! "paired") (node-deliver-text! "{\"t\":\"hello\",\"instance\":\"local\"}") (node-deliver-text! "{\"t\":\"auth-ok\",\"grants\":[]}") (enclave-node-reset!) (enclave-node-register!) (node-clear-sent!))(define (complete-open! carrier transport) (let* ((raw (car (node-sent-text))) (_string (assert-true (string? raw))) (open (json-decode raw)) (request (dict-ref open id: #f))) (node-deliver-text! (json-encode #{ t: "ev" event: "remote/opened" carrier: carrier request: request transport: transport })) (assert-equal 2 (length (node-sent-text))) request))(define (last-carrier-request) (let* ((raw (car (reverse (node-sent-text)))) (outer (if (string? raw) (json-decode raw) raw)) (payload (dict-ref outer payload: #f)) (inner (if (string? payload) (json-decode payload) payload))) #{ outer: outer inner: inner }))(define (sent-json-texts) (filter-map (lambda (raw) (and (string? raw) (guard (e (#t #f)) (json-decode raw)))) (node-sent-text)))(define (open-request-for target) (find (lambda (message) (and (equal? (dict-ref message verb: #f) "remote/open") (equal? target (dict-ref (dict-ref message args: #{}) target: #f)))) (sent-json-texts)))(define (carrier-request-for carrier) (find (lambda (message) (and (equal? (dict-ref message t: #f) "remote/text") (equal? (dict-ref message carrier: #f) carrier))) (sent-json-texts)))(test-group "enclave-node coarse requests over companion carriers" (test "issues a carrier open for a coarse request" (ready-loopback!) (enclave-node-sys-info! "phone" (lambda (_) #t) (lambda (_) #f)) (assert-equal 1 (length (node-sent-text))) (let ((open (json-decode (car (node-sent-text))))) (assert-equal "remote/open" (dict-ref open verb: #f)) (assert-true (number? (dict-ref open id: #f))) (assert-equal 2147483648 (dict-ref open id: #f))) (complete-open! 5 "direct") (assert-equal 'direct (enclave-node-carrier-transport "phone")) (assert-false (enclave-node-carrier-transport "tablet")) (assert-equal 2 (length (node-sent-text)))) (test "ready direct carrier promotes an offline root by opaque host identity" (ready-loopback!) (register-root! zerocool: #{ provider: node: host: "phone" label: "zerocool" class: machine: status: offline: planes: (list resource: session:) }) (enclave-node-sys-info! "phone" (lambda (_) #t) (lambda (_) #f)) (complete-open! 6 "direct") (assert-equal online: (dict-ref (root-ref zerocool:) status: #f))) (test "inline coarse reply uses the selected direct carrier" (ready-loopback!) (let ((result #f) (failure #f)) (enclave-node-sys-info! "phone" (lambda (value) (set! result value)) (lambda (error) (set! failure error))) (assert-true (node-ready?)) (assert-equal 1 (length (node-sent-text))) (complete-open! 7 "direct") (let* ((sent (last-carrier-request)) (outer (dict-ref sent outer: #{})) (inner (dict-ref sent inner: #{})) (id (dict-ref inner id: #f))) (assert-equal "remote/text" (dict-ref outer t: #f)) (assert-equal "sys/info" (dict-ref inner verb: #f)) (node-deliver-text! (json-encode (carrier-text-envelope 7 (json-encode #{ t: "ok" id: id result: #{ node: "phone" } }))))) (assert-false failure) (assert-equal "phone" (dict-ref result node: #f)))) (test "account loss preserves a ready direct root and its in-flight request" (ready-loopback!) (register-root! phone: #{ provider: node: host: "phone" label: "phone" class: machine: status: online: planes: (list resource: session:) }) (let ((result #f) (failure #f)) (enclave-node-sys-info! "phone" (lambda (value) (set! result value)) (lambda (error) (set! failure error))) (complete-open! 8 "direct") (let* ((sent (last-carrier-request)) (inner (dict-ref sent inner: #{})) (id (dict-ref inner id: #f))) (enclave-node-account-lost! "connection lost" 1000) (assert-equal 1 (enclave-node-pending-count)) (assert-equal online: (dict-ref (root-ref phone:) status: #f)) (assert-false failure) (node-deliver-text! (json-encode (carrier-text-envelope 8 (json-encode #{ t: "ok" id: id result: #{ node: "phone" } })))) (assert-equal "phone" (dict-ref result node: #f)) (assert-equal 0 (enclave-node-pending-count))) (unregister-root! phone:) (node-reset!))) (test "failed carrier acquisition retries the compatibility backend" (ready-loopback!) (let ((failure #f)) (enclave-node-sys-info! "phone" (lambda (_) #f) (lambda (error) (set! failure error))) (let* ((open (json-decode (car (node-sent-text)))) (request (dict-ref open id: #f))) (node-deliver-text! (json-encode #{ t: "ev" event: "remote/failed" request: request message: "direct and relay unavailable" }))) (assert-equal "Enclave account is not connected" failure) (assert-equal 0 (enclave-node-pending-count)))) (test "bulk file reply is correlated by carrier and request id" (ready-loopback!) (let ((result #f) (failure #f)) (enclave-node-sys-info! "phone" (lambda (value) (set! result value)) (lambda (error) (set! failure error))) (assert-true (node-ready?)) (assert-equal 1 (length (node-sent-text))) (complete-open! 11 "relay") (assert-equal 'relay (enclave-node-carrier-transport "phone")) (let* ((sent (last-carrier-request)) (inner (dict-ref sent inner: #{})) (id (dict-ref inner id: #f))) (node-deliver-text! (json-encode (carrier-text-envelope 11 (json-encode #{ t: "ok" id: id bulk: #t })))) ;; Wrong-carrier bulk is ignored. (node-deliver-binary! (carrier-frame-encode 12 (frame-encode FRAME-BULK id (wire-encode "bad")))) (assert-false result) (node-deliver-binary! (carrier-frame-encode 11 (frame-encode FRAME-BULK id (wire-encode "hello")))) (assert-false failure) (assert-equal "hello" result) ;; Return the shared loopback singleton to idle for later test files. (node-reset!))))) (test "directory mutations use the selected carrier and canonical verbs" (ready-loopback!) (let ((done 0) (failure #f) (dir #{ provider: node: kind: dir: host: "phone" address: "/root/new" }) (moved #{ provider: node: kind: dir: host: "phone" address: "/root/moved" })) (source-mkdir dir (lambda (_) (set! done (+ done 1))) (lambda (e) (set! failure e))) (complete-open! 13 "direct") (let* ((sent (last-carrier-request)) (inner (dict-ref sent inner: #{})) (id (dict-ref inner id: #f))) (assert-equal "fs/mkdir" (dict-ref inner verb: #f)) (assert-equal "/root/new" (dict-ref (dict-ref inner args: #{}) path: #f)) (node-deliver-text! (json-encode (carrier-text-envelope 13 (json-encode #{ t: "ok" id: id result: #{ ok: #t } }))))) (source-rename dir moved (lambda (_) (set! done (+ done 1))) (lambda (e) (set! failure e))) (let* ((sent (last-carrier-request)) (inner (dict-ref sent inner: #{})) (id (dict-ref inner id: #f)) (args (dict-ref inner args: #{}))) (assert-equal "fs/rename" (dict-ref inner verb: #f)) (assert-equal "/root/new" (dict-ref args from: #f)) (assert-equal "/root/moved" (dict-ref args to: #f)) (node-deliver-text! (json-encode (carrier-text-envelope 13 (json-encode #{ t: "ok" id: id result: #{ ok: #t } }))))) (source-delete moved (lambda (_) (set! done (+ done 1))) (lambda (e) (set! failure e))) (let* ((sent (last-carrier-request)) (inner (dict-ref sent inner: #{})) (id (dict-ref inner id: #f)) (args (dict-ref inner args: #{}))) (assert-equal "fs/delete" (dict-ref inner verb: #f)) (assert-equal "/root/moved" (dict-ref args path: #f)) (assert-true (dict-ref args recursive: #f)) (node-deliver-text! (json-encode (carrier-text-envelope 13 (json-encode #{ t: "ok" id: id result: #{ ok: #t } }))))) (assert-false failure) (assert-equal 3 done) (assert-equal 'direct (enclave-node-carrier-transport "phone")) (node-reset!))) (test "one root's carrier loss cannot settle or retire another root's work" (ready-loopback!) (let ((phone-result #f) (phone-error #f) (laptop-result #f) (laptop-error #f)) (enclave-node-sys-info! "phone" (lambda (value) (set! phone-result value)) (lambda (error) (set! phone-error error))) (enclave-node-sys-info! "laptop" (lambda (value) (set! laptop-result value)) (lambda (error) (set! laptop-error error))) (let ((phone-open (open-request-for "phone")) (laptop-open (open-request-for "laptop"))) (assert-true phone-open) (assert-true laptop-open) ;; Complete in the opposite order from issuance to prove request ;; correlation, then keep both roots concurrently ready. (node-deliver-text! (json-encode #{ t: "ev" event: "remote/opened" carrier: 42 request: (dict-ref laptop-open id: #f) transport: "relay" })) (node-deliver-text! (json-encode #{ t: "ev" event: "remote/opened" carrier: 41 request: (dict-ref phone-open id: #f) transport: "direct" }))) (let* ((phone-outer (carrier-request-for 41)) (laptop-outer (carrier-request-for 42)) (phone-inner (json-decode (dict-ref phone-outer payload: ""))) (laptop-inner (json-decode (dict-ref laptop-outer payload: "")))) (node-deliver-text! (json-encode #{ t: "ev" event: "remote/closed" carrier: 41 })) (assert-equal "connection lost to phone" phone-error) (assert-false phone-result) (assert-false (enclave-node-carrier-transport "phone")) (assert-equal 'relay (enclave-node-carrier-transport "laptop")) (assert-false laptop-error) (node-deliver-text! (json-encode (carrier-text-envelope 42 (json-encode #{ t: "ok" id: (dict-ref laptop-inner id: #f) result: #{ node: "laptop" } })))) ;; A late phone reply, even with its correct request id, cannot cross ;; the surviving laptop carrier and resurrect retired work. (node-deliver-text! (json-encode (carrier-text-envelope 42 (json-encode #{ t: "ok" id: (dict-ref phone-inner id: #f) result: #{ node: "wrong" } })))) (assert-equal "laptop" (dict-ref laptop-result node: #f)) (assert-equal "connection lost to phone" phone-error) (assert-equal 0 (enclave-node-pending-count)) (node-reset!))))(test "remote image operation streams binary bytes into the browser Blob handoff" (ready-loopback!) (let ((url #f) (failure #f) (image-op (dict-ref (provider-ref node:) image-url: #f))) (assert-true (procedure? image-op)) (image-op #{ provider: node: host: "phone" kind: file: address: "/photos/TEST.PNG" } (lambda (value) (set! url value)) (lambda (error) (set! failure error))) (complete-open! 77 "direct") (let* ((inner (dict-ref (last-carrier-request) inner: #{})) (id (dict-ref inner id: #f))) (assert-equal "fs/read-file" (dict-ref inner verb: #f)) (assert-equal "/photos/TEST.PNG" (dict-ref (dict-ref inner args: #{}) path: #f)) (assert-true (dict-ref (dict-ref inner args: #{}) transfer: #f)) (node-deliver-text! (json-encode (carrier-text-envelope 77 (json-encode #{ t: "ok" id: id transfer: #{ direction: "download" size: 4 chunk-bytes: 65536 } })))) (node-deliver-binary! (carrier-frame-encode 77 (frame-encode #x20 id (bytevector 0 0 0 0 137 80 0 255)))) (assert-false (node-blob-waiting?)) (node-deliver-binary! (carrier-frame-encode 77 (frame-encode #x21 id (bytevector 0 0 0 1 0 0 0 4)))) (assert-false url) (assert-true (node-blob-waiting?)) (let ((token (node-blob-poll))) (assert-equal (bytevector 137 80 0 255) (node-blob-bytes)) (assert-equal "image/png" (node-blob-mime)) (node-blob-ready token "blob:remote-image") (assert-equal "blob:remote-image" url) (assert-equal 0 (bytevector-length (node-blob-bytes)))) (assert-false failure) (assert-equal 0 (enclave-node-transfer-count)))))(test "remote image failures reach the preview without queuing a Blob" (ready-loopback!) (let ((failure #f)) ((dict-ref (provider-ref node:) image-url:) #{ provider: node: host: "phone" kind: file: address: "/missing.svg" } (lambda (_) (error "unexpected image")) (lambda (error) (set! failure error))) (complete-open! 78 "direct") (let ((id (dict-ref (dict-ref (last-carrier-request) inner: #{}) id: #f))) (node-deliver-text! (json-encode (carrier-text-envelope 78 (json-encode #{ t: "err" id: id code: "not-found" message: "image missing" }))))) (assert-true failure) (assert-false (node-blob-waiting?))))(test "remote Git requests preserve structured repository errors" (ready-loopback!) (let ((failure #f)) ((dict-ref (provider-ref node:) git-request:) #{ provider: node: host: "phone" kind: dir: address: "/photos" } "git/discover" #{ path: "/photos" } (lambda (_) (error "unexpected Git result")) (lambda (error) (set! failure error))) (complete-open! 79 "direct") (let ((id (dict-ref (dict-ref (last-carrier-request) inner: #{}) id: #f))) (node-deliver-text! (json-encode (carrier-text-envelope 79 (json-encode #{ t: "err" id: id code: "not-a-repository" message: "not a Git repository" }))))) (assert-equal "not-a-repository" (dict-ref failure code: #f)) (assert-equal "not a Git repository" (dict-ref failure message: #f))))packages/slate/test/enclave-node-carrier-session-test.sgladded
(import (sigil core) (sigil test) (sigil io) (sigil json) (slate provider) (slate providers node) (slate providers enclave-node) (slate providers enclave-node carrier))(define (ready-session-loopback!) (node-reset!) (node-set-transport!) (node-set-token! "paired") (node-deliver-text! "{\"t\":\"hello\",\"instance\":\"local\"}") (node-deliver-text! "{\"t\":\"auth-ok\",\"grants\":[]}") (enclave-node-reset!) (enclave-node-register!) (node-clear-sent!))(define (decode-last-text) (json-decode (car (reverse (node-sent-text)))))(define (complete-session-carrier! carrier) (let* ((open (json-decode (car (node-sent-text)))) (request (dict-ref open id: #f))) (node-deliver-text! (json-encode #{ t: "ev" event: "remote/opened" carrier: carrier request: request transport: "direct" }))))(test-group "remote sessions over companion carriers" (test "open, queued input, output credit, and close keep the inner protocol" (ready-session-loopback!) (let ((emitted #f) (lost #f) (failure #f)) (let ((ops (remote-session-open #{ host: "phone" kind: pty: cwd: "/tmp" argv: (list "bash" "-l") cols: 90 rows: 30 } #{ sid: "seam-1" emit: (lambda (text) (set! emitted text)) lost: (lambda (at) (set! lost #t)) err: (lambda (error) (set! failure error)) }))) (assert-equal "remote/open" (dict-ref (json-decode (car (node-sent-text))) verb: #f)) (complete-session-carrier! 17) (let* ((outer (decode-last-text)) (inner (json-decode (dict-ref outer payload: ""))) (rid (dict-ref inner id: #f))) (assert-equal "session/open" (dict-ref inner verb: #f)) (assert-equal "/tmp" (dict-ref (dict-ref inner args: #{}) cwd: #f)) ;; Input before the open reply is queued and rebound to the assigned ;; channel when the reply arrives. ((dict-ref ops write:) "hello") (node-deliver-text! (json-encode (carrier-text-envelope 17 (json-encode #{ t: "ok" id: rid result: #{ chan: 42 sid: "node-session" } })))) (let* ((sent (carrier-frame-decode (car (node-sent-binary)))) (inner-frame (dict-ref sent payload: #f)) (decoded (frame-decode inner-frame))) (assert-equal 2 (bytevector-u8-ref inner-frame 0)) (assert-equal 42 (dict-ref decoded id: #f)) (assert-equal "hello" (utf8->string (bytevector-copy inner-frame 5 (bytevector-length inner-frame))))) (node-clear-sent!) (node-poll!) (assert-false (node-drained-output?)) (node-deliver-binary! (carrier-frame-encode 17 (frame-encode 3 42 (string->utf8 "world")))) (assert-equal "world" emitted) (assert-true (node-drained-output?)) (let* ((credit (carrier-frame-decode (car (node-sent-binary)))) (inner-frame (dict-ref credit payload: #f)) (decoded (frame-decode inner-frame))) (assert-equal 7 (bytevector-u8-ref inner-frame 0)) (assert-equal 42 (dict-ref decoded id: #f))) ((dict-ref ops close:)) (assert-equal 0 (enclave-node-stream-count)) (assert-false failure) (assert-false lost))))) (test "carrier closure retires and reports only its bound session" (ready-session-loopback!) (let ((lost #f)) (remote-session-open #{ host: "phone" kind: pty: } #{ sid: "seam-2" lost: (lambda (_) (set! lost #t)) }) (complete-session-carrier! 19) (let* ((outer (decode-last-text)) (inner (json-decode (dict-ref outer payload: ""))) (rid (dict-ref inner id: #f))) (node-deliver-text! (json-encode (carrier-text-envelope 19 (json-encode #{ t: "ok" id: rid result: #{ chan: 44 sid: "node-session-2" } }))))) (assert-equal 1 (enclave-node-stream-count)) (node-deliver-text! (json-encode #{ t: "ev" event: "remote/closed" carrier: 19 })) (assert-true lost) (assert-equal 0 (enclave-node-stream-count)) (node-reset!))) (test "the bounded ladder reattaches over a fresh carrier" (ready-session-loopback!) (let ((events '())) (let ((handle (source-session-open #{ provider: node: host: "phone" kind: pty: } (lambda (event) (set! events (cons event events))) #{}))) (complete-session-carrier! 21) (let* ((outer (decode-last-text)) (inner (json-decode (dict-ref outer payload: ""))) (rid (dict-ref inner id: #f))) (node-deliver-text! (json-encode (carrier-text-envelope 21 (json-encode #{ t: "ok" id: rid result: #{ chan: 51 sid: "durable-1" } }))))) (node-clear-sent!) (node-deliver-text! (json-encode #{ t: "ev" event: "remote/closed" carrier: 21 })) (assert-equal 1 (enclave-node-reattach-count)) ;; First tick stamps the floor; the second issues exactly one attach. (enclave-node-tick! 1000) (enclave-node-tick! 2000) (assert-equal 1 (length (node-sent-text))) (assert-equal "remote/open" (dict-ref (json-decode (car (node-sent-text))) verb: #f)) (assert-equal "durable-1" (dict-ref (dict-ref (json-decode (car (node-sent-text))) args: #{}) sid: #f)) (complete-session-carrier! 22) (let* ((outer (decode-last-text)) (inner (json-decode (dict-ref outer payload: ""))) (rid (dict-ref inner id: #f))) (assert-equal "session/attach" (dict-ref inner verb: #f)) (assert-equal "durable-1" (dict-ref (dict-ref inner args: #{}) sid: #f)) (node-deliver-text! (json-encode (carrier-text-envelope 22 (json-encode #{ t: "ok" id: rid result: #{ chan: 52 sid: "durable-1" } }))))) (assert-equal 0 (enclave-node-reattach-count)) ;; complete-session-carrier! explicitly reports a direct selection; ;; the reattach event must preserve that transport rather than infer it. (assert-equal 'direct (dict-ref (car events) transport: #f)) (assert-true (dict-ref (car events) reattached: #f)) (session-close handle) (node-reset!)))) (test "loopback loss fails pinned resources and arms bound sessions" (ready-session-loopback!) (let ((lost? #f) (resource-error #f)) (let ((handle (source-session-open #{ provider: node: host: "phone" kind: pty: } (lambda (event) (when (dict-ref event disconnected: #f) (set! lost? #t))) #{}))) (complete-session-carrier! 31) (let* ((outer (decode-last-text)) (inner (json-decode (dict-ref outer payload: ""))) (rid (dict-ref inner id: #f))) (node-deliver-text! (json-encode (carrier-text-envelope 31 (json-encode #{ t: "ok" id: rid result: #{ chan: 61 sid: "durable-loss" } }))))) (enclave-node-sys-info! "phone" (lambda (_) #f) (lambda (error) (set! resource-error error))) (assert-equal 1 (enclave-node-pending-count)) (node-deliver-disconnect! 1) (assert-equal "connection lost to phone" resource-error) (assert-true lost?) (assert-equal 0 (enclave-node-stream-count)) (assert-equal 0 (enclave-node-pending-count)) (assert-equal 1 (enclave-node-reattach-count)) (session-close handle) (node-reset!)))))packages/slate/test/enclave-node-carrier-test.sgladded
(import (sigil core) (sigil test) (slate providers enclave-node carrier) (slate providers node))(test-group "companion remote carrier adapter" (test "open completion binds one serialized request and exposes transport" (let* ((registry (make-carrier-registry)) (seen #f) (_entry (carrier-open! registry "phone" (lambda (entry) (set! seen entry)) (lambda (error) #f))) (ready (carrier-on-control! registry #{ t: "ev" event: "remote/opened" carrier: 7 request: 2147483648 transport: "direct" fallback-reason: #f }))) (assert-equal 'ready (dict-ref ready state: #f)) (assert-equal 'direct (dict-ref seen transport: #f)) (assert-equal "phone" (dict-ref (carrier-find-target registry "phone") target: #f)))) (test "text and binary envelopes preserve opaque node frames" (let* ((payload (bytevector 2 0 0 0 9 65 66)) (decoded (carrier-frame-decode (carrier-frame-encode 42 payload))) (text (carrier-text-envelope 42 "{\"t\":\"req\"}"))) (assert-equal 42 (dict-ref decoded carrier: 0)) (assert-equal payload (dict-ref decoded payload: #f)) (assert-equal "remote/text" (dict-ref text t: #f)) (assert-equal "{\"t\":\"req\"}" (dict-ref text payload: #f)))) (test "close removes the carrier before reporting loss" (let* ((registry (make-carrier-registry)) (removed? #f) (_entry (carrier-open! registry "phone" (lambda (_) #f) (lambda (_) (set! removed? (not (carrier-ref registry 7))))))) (carrier-on-control! registry #{ t: "ev" event: "remote/opened" carrier: 7 request: 2147483648 transport: "relay" }) (carrier-on-control! registry #{ t: "ev" event: "remote/closed" carrier: 7 }) (assert-true removed?))) (test "malformed and unmatched events fail closed" (let ((registry (make-carrier-registry))) (assert-false (carrier-on-control! registry #{ t: "ev" event: "remote/opened" carrier: 0 transport: "direct" })) (assert-false (carrier-frame-decode (bytevector #x11 0 0 0 0))))) (test "ensure coalesces same-target opens and correlates concurrent targets" (let ((registry (make-carrier-registry)) (sent '()) (phone-ready 0) (laptop-ready 0)) (define (send value) (set! sent (cons value sent)) #t) (carrier-ensure! registry "phone" send (lambda (_) (set! phone-ready (+ phone-ready 1))) (lambda (_) #f)) (carrier-ensure! registry "phone" send (lambda (_) (set! phone-ready (+ phone-ready 1))) (lambda (_) #f)) (carrier-ensure! registry "laptop" send (lambda (_) (set! laptop-ready (+ laptop-ready 1))) (lambda (_) #f)) (assert-equal 2 (length sent)) (carrier-on-control! registry #{ t: "ev" event: "remote/opened" carrier: 9 request: 2147483649 transport: "relay" }) (assert-equal 0 phone-ready) (assert-equal 1 laptop-ready) (carrier-on-control! registry #{ t: "ev" event: "remote/opened" carrier: 8 request: 2147483648 transport: "direct" }) (assert-equal 2 phone-ready))) (test "failed opens correlate by request before a carrier exists" (let ((registry (make-carrier-registry)) (failure #f)) (carrier-ensure! registry "phone" (lambda (_) #t) (lambda (_) #f) (lambda (reason) (set! failure reason))) (carrier-on-control! registry #{ t: "ev" event: "remote/failed" request: 2147483648 message: "direct and relay unavailable" }) (assert-equal "remote transport unavailable" failure) (assert-false (carrier-find-target registry "phone")))) (test "disconnect drains before callbacks and preserves replacement work" (let ((registry (make-carrier-registry)) (failed 0) (empty-before-callback? #f)) (carrier-ensure! registry "phone" (lambda (_) #t) (lambda (_) #f) (lambda (_) (set! empty-before-callback? (not (carrier-find-target registry "phone"))) (set! failed (+ failed 1)) (carrier-ensure! registry "replacement" (lambda (_) #t) (lambda (_) #f) (lambda (_) #f)))) (carrier-disconnect-all! registry "loopback lost") (assert-true empty-before-callback?) (assert-equal 1 failed) (assert-false (carrier-find-target registry "phone")) (assert-true (carrier-find-target registry "replacement")))) (test "loopback disconnect ignores stale generations and is idempotent" (node-reset!) (node-set-transport!) (node-set-token! "paired") (node-deliver-text! "{\"t\":\"hello\",\"instance\":\"local\"}") (node-deliver-text! "{\"t\":\"auth-ok\",\"grants\":[]}") (let ((closed 0)) (node-on-remote-disconnect! (lambda (_) (set! closed (+ closed 1)))) (node-deliver-disconnect! 0) (assert-true (node-ready?)) (node-deliver-disconnect! 1) (node-deliver-disconnect! 1) (assert-equal 1 closed) (assert-false (node-ready?)))) (test "loopback node reserves remote envelopes for the carrier adapter" (node-reset!) (node-set-transport!) (let ((control #f) (binary #f)) (node-on-remote-control! (lambda (message) (set! control message))) (node-on-remote-binary! (lambda (bytes) (set! binary bytes))) (node-send-raw-text! (carrier-open-request 2147483648 "phone")) (node-send-raw-binary! (carrier-frame-encode 4 (bytevector 3 0 0 0 1 65))) (node-deliver-text! "{\"t\":\"ev\",\"event\":\"remote/opened\",\"carrier\":4,\"request\":2147483648,\"transport\":\"direct\"}") (node-deliver-binary! (carrier-frame-encode 4 (bytevector 3 0 0 0 1 66))) (assert-equal "remote/opened" (dict-ref control event: #f)) (assert-equal 4 (dict-ref (carrier-frame-decode binary) carrier: 0)) (assert-equal 1 (length (node-sent-text))) (assert-equal 1 (length (node-sent-binary))))))packages/slate/test/enclave-node-kind-test.sglmodified
(sigil string) (slate providers enclave-node))(test-group "remote root home discovery barrier" (test "offline registration cannot settle an online home request" (assert-equal 1 (discovery-pending-after-registration 1 #f))) (test "an online home reply settles exactly one request" (assert-equal 1 (discovery-pending-after-registration 2 #t))) (test "a duplicate completion cannot underflow the barrier" (assert-equal 0 (discovery-pending-after-registration 0 #t))));; ============================================================;; 1. The lowering: source keyword -> wire string;; ============================================================ (assert-equal 200 (dict-ref a cols: #f)) (assert-equal 60 (dict-ref a rows: #f)))) (test "a remote terminal preserves its selected working directory" (assert-equal "/Users/daviwil" (dict-ref (session-open-args #{ kind: pty: argv: (list "bash" "-l") cwd: "/Users/daviwil" }) cwd: #f))) (test "defaults apply per-field when the source omits them" (let ((a (session-open-args #{ kind: pty: }))) (assert-equal (list "bash" "-l") (dict-ref a argv: #f)) (assert-false (dict-ref a cwd: #f)) (assert-equal 80 (dict-ref a cols: #f)) (assert-equal 24 (dict-ref a rows: #f)))))packages/slate/test/enclave-node-reattach-tick-test.sglmodified
;; with slot 10 hardwired `#f`, a future re-MEANING of that slot (say to;; `not-mine?`) would invert what every arm tests and stay green. The;; "REFUSALS" group below drives both.(define (make-lost-stream!* ctx sid mine?)(define (make-lost-stream!* ctx sid mine? . carrier?) (let* ((state (vector sid #f)) (cb #{ source: test-source ctx: ctx state: state }) (st (vector 'no-socket "ticket" #t 7 cb #f '() state "session/open" sid mine? #f))) (st (if (and (pair? carrier?) (car carrier?)) (vector 'no-socket "ticket" #t 7 cb #f '() state "session/open" sid mine? #f 'carrier 41) (vector 'no-socket "ticket" #t 7 cb #f '() state "session/open" sid mine? #f)))) (vector-set! state 1 st) st)) (assert-equal 0 (stream-opens-sent)) (assert-true (field=? (only-entry) "attempts" "0")))))(test-group "a replacement companion preserves the session's principal" (test "a carrier-backed loss never falls through to the account relay" ;; A durable sid is scoped to the node principal that opened its holder. ;; The account relay may be healthy while the companion restarts, but it is ;; not an equivalent acquisition path: issuing there changes the principal ;; and produces `no-session` against a surviving direct holder. (fresh-armed-entry!) (reach-ready!) (let ((carrier-st (make-lost-stream!* *session-ctx* node-sid #f #t))) (notify-stream-lost! carrier-st) (enclave-node-tick! t0) (enclave-node-tick! (+ t0 floor-ms)) (assert-equal 0 (stream-opens-sent)) (assert-true (field=? (only-entry) "attempts" "0")) (assert-true (field=? (only-entry) "next-at-ms" (number->string (+ t0 (* 2 floor-ms))))) (enclave-node-tick! (+ t0 (* 2 floor-ms))) (assert-equal 0 (stream-opens-sent)) (assert-true (field=? (only-entry) "attempts" "0")) (assert-true (field=? (only-entry) "next-at-ms" (number->string (+ t0 (* 3 floor-ms))))) (enclave-node-tick! (+ t0 (* 10 floor-ms))) (assert-equal 0 (stream-opens-sent)) (assert-true (field=? (only-entry) "attempts" "0")))))(test-group "the shell's bounds, through the tick rather than through the decision" (test "AN OUTSTANDING ATTACH IS NOT RE-ISSUED while its RPC is still alive" ;; `in-flight?:` is the bound that makes "no duplicates" true. It is SET by ;; unexercised, and a future RE-MEANING of slot 10 (say to `not-mine?`) would ;; invert what the whole file tests while staying green. (test "a superseded stream cannot overwrite a successful reattach with lost" (fresh-armed-entry!) (reach-ready!) (let* ((lost-events 0) (ctx #{ lost: (lambda (at) (set! lost-events (+ lost-events 1))) }) (old (make-lost-stream! ctx)) (state (vector-ref old 7))) ;; Model the field ordering: a successful attach installs its replacement ;; first, then the old carrier reports its delayed close. (vector-set! state 1 'replacement-stream) (notify-stream-lost! old) (assert-equal 0 lost-events) (assert-equal 0 (enclave-node-reattach-count)))) (test "A STREAM WE CLOSED OURSELVES ARMS NOTHING — slot 10 driven as #t" ;; `session-detach!`'s ok path closes its own socket, after which the pump ;; lands here. Arming would re-attach exactly what the caller asked topackages/slate/test/enclave-remote-live.sglmodified
(import (sigil core) (sigil io) (sigil process) (sigil string) (sigil time) (sigil async) (sigil test) (slate provider) (slate enclave-account session) (slate providers node) (slate providers enclave-node resource) (slate providers enclave-node))(define endpoint (getenv "P3S_ENCLAVE_WS"))(define node-name (getenv "P3S_NODE"))(define readable-path (getenv "P3S_READABLE_PATH"))(define denied-path (getenv "P3S_DENIED_PATH"))(define image-path (getenv "P3S_IMAGE_PATH"))(define pty-marker (getenv "P3S_PTY_MARKER"))(define session-sentinel (getenv "P3T_SESSION_SENTINEL"))(unless (and (string? file-text) (string-contains? file-text "p3s-readable-marker")) (error "remote file returned the wrong content" file-value));; No local companion: the image must bypass the account RPC payload limit.(assert-false (node-ready?))(define image-result #f)(define image-error #f)((dict-ref (provider-ref node:) image-url:) #{ provider: node: kind: file: host: node-name address: image-path } (lambda (url) (set! image-result url)) (lambda (reason) (set! image-error reason)))(pump-until "large image uses the real binary relay without a companion" (lambda () (or image-error (node-blob-waiting?))) 3000)(when image-error (error "remote image failed" image-error))(let ((token (node-blob-poll))) (assert-equal 1600000 (bytevector-length (node-blob-bytes))) (assert-equal 137 (bytevector-u8-ref (node-blob-bytes) 0)) (assert-equal 0 (bytevector-u8-ref (node-blob-bytes) 1599999)) (assert-equal "image/png" (node-blob-mime)) (node-blob-ready token "blob:live-image"))(assert-equal "blob:live-image" image-result)(assert-equal 0 (enclave-resource-count))(set! failure #f)(let* ((source #{ provider: node: kind: file: host: node-name address: denied-path }) (op (provider-async-op source)))packages/slate/test/findfile-test.sglmodified
(import (sigil core) (sigil test) (slate findfile)) (slate findfile) (slate provider) (slate root));; A source on a multi-machine provider must keep the root that carries its;; routing host. Returning only `node:` sends the initial find-file read to an;; empty hostname; selecting the same root manually then appears to fix it.(test "source-fs-provider preserves the matching remote root and host context" (register-provider! ff-shared-node: #{}) (register-root-silent! ff-cereal: #{ provider: ff-shared-node: host: "cereal" class: machine: }) (register-root-silent! ff-nynex: #{ provider: ff-shared-node: host: "nynex" class: machine: }) (let ((actual (source-fs-provider #{ provider: ff-shared-node: host: "cereal" kind: dir: address: "/Users/daviwil" } browser:))) (unregister-root! ff-cereal:) (unregister-root! ff-nynex:) (assert-equal ff-cereal: actual)));; ---- the completion boundary ---------------------------------------------;; findfile-dir answers "which directory is this query completing in": everything (let loop ((i 0) (acc '())) (if (< i 10000) (loop (+ i 1) (cons i acc)) (assert-equal (length (take-at-most 200 acc)) 200)))) (assert-equal (length (take-at-most 50 acc)) 50))));; ---- the listing cache ----------------------------------------------------;; Driven over a FAKE read that counts calls and defers delivery, so the same code(define (cache-entries cache key reader) ((dict-ref cache entries:) key (dict-ref reader read!:)))(test "listing cache reuses derived rows per directory and resets them together" (let ((cache (make-listing-cache (lambda () #t))) (builds 0)) (define (rows) ((dict-ref cache rows:) "local:/work/|all" (lambda () (set! builds (+ builds 1)) (list (entry "a.sgl" file:))))) (rows) (rows) (assert-equal builds 1) ((dict-ref cache reset!:)) (rows) (assert-equal builds 2)))(test "listing cache: a miss reports 'loading and kicks off exactly one read" (let* ((cache (make-listing-cache (lambda () #t))) (r (make-fake-reader)))packages/slate/test/folder-command-test.sglmodified
(import (sigil core) (sigil test) (slate command) (slate commands folder) (slate foldernav) (slate pane) (slate providers slate-meta) (slate space) (slate view)) (slate modes folder) (slate pane) (slate provider) (slate root) (slate foldernav) (slate providers slate-meta) (slate space) (slate state) (slate view))(test "the complete folder family resolves from one module" (for-each (lambda (event) (assert-equal (command-module (command-resolve event)) '(slate commands folder))) slate/spaces: (list (make-space "main" (make-leaf 0 "plain") 0 1)) slate/active-space: "main" })) (assert-equal (command-run state "folder-open") state)))(register-provider! folder-fixture: #{ materialize: (lambda (source) (list #{ name: "child" kind: dir: })) })(define (fixture-folder address) (let ((source #{ provider: folder-fixture: kind: dir: address: address })) (make-folder-view (source-root-display-name source) source)))(define (fixture-state folder) #{ slate/views: (list folder) slate/spaces: (list (make-space "main" (make-leaf 0 (view-name folder)) 0 1)) slate/active-space: "main" })(define (fixture-open-source state source) (ensure-view state (source-root-display-name source) (lambda () (make-folder-view (source-root-display-name source) source))))(define (configure-fixture!) (configure-folder-commands! #{ open-source: fixture-open-source prefix-arg?: (lambda (state) #f) same-source-place?: (lambda (a b) #t) }))(test "folder descent reuses the current view and parent navigation returns" (configure-fixture!) (let* ((origin (folder-toggle-preview (fixture-folder "/from"))) (next (command-run (fixture-state origin) "folder-open")) (parent (command-run next "folder-parent"))) (assert-equal (folder-address (active-view next)) "/from/child") (assert-true (dict-ref (active-view next) slate/folder-preview?: #f)) (assert-equal (length (views next)) 1) (assert-equal (folder-address (active-view parent)) "/from") (assert-true (dict-ref (active-view parent) slate/folder-preview?: #f)) (assert-equal (length (views parent)) 1)))(test "folder reuse preserves explicit keep and views displayed in another pane" (configure-fixture!) (let* ((origin (fixture-folder "/from")) (state (fixture-state origin)) (source #{ provider: folder-fixture: kind: dir: address: "/to" }) (kept (open-folder-reusing state origin source #t)) (split (update-active-space state (lambda (space) (space-split space 'row)))) (reused (open-folder-reusing split origin source #f))) (assert-equal (length (views kept)) 2) (assert-equal (length (views reused)) 2) (assert-equal (folder-address (active-view reused)) "/to") (assert-true (find-view (views reused) (view-name origin)))))(test "folder parent at the root opens the root directory listing" (let ((target #f) (origin (fixture-folder "/"))) (configure-folder-commands! #{ prefix-arg?: (lambda (state) #f) open-source: (lambda (state source) (set! target source) state) }) (command-run (fixture-state origin) "folder-parent") (assert-equal (dict-ref target provider:) slate:) (assert-equal (dict-ref target address:) "/roots")))(test "folder copy and rename prompts use the other pane without mutating files" (configure-fixture!) (let* ((origin (fixture-folder "/from")) (other (fixture-folder "/to")) (state #{ slate/views: (list origin other) slate/spaces: (list (make-space "main" (make-split 'row (make-leaf 0 (view-name origin)) (make-leaf 1 (view-name other))) 0 2)) slate/active-space: "main" })) (for-each (lambda (event) (let ((next (command-run state event))) (assert-equal (dict-ref next slate/minibuffer:) "folder-input") (assert-equal (dict-ref next slate/selector-query:) "/to/child") (assert-equal (views next) (views state)))) (list "folder-copy" "folder-rename"))))(test "closing a folder returns to the remaining view" (let* ((origin (fixture-folder "/from")) (plain (view slate/name: "plain")) (state (dict-set (fixture-state origin) slate/views: (list plain origin))) (next (command-run state "folder-quit"))) (assert-equal (views next) (list plain)) (assert-equal (view-name (active-view next)) "plain")))(configure-folder-commands! #{})(test "open-folder reaches the shared filesystem candidate layer without an unbound call" (let* ((plain (view slate/name: "plain")) (state #{ slate/views: (list plain)packages/slate/test/folder-display-test.sglmodified
(v2 (folder-mark v1)) (v3 (folder-unmark (folder-set-point v2 0))) (v4 (folder-toggle-marks v3))) (assert-equal (folder-marks v1) (list "one.txt")) (assert-equal (folder-marks v1) (list "three")) (assert-equal (folder-point v1) 1) (assert-equal (folder-marks v2) (list "one.txt" "two.txt")) (assert-equal (folder-marks v2) (list "three" "one.txt")) (assert-equal (folder-point v2) 2) (assert-equal (folder-marks v3) (list "two.txt")) (assert-equal (folder-marks v3) (list "one.txt")) (assert-equal (folder-point v3) 1) (assert-equal (folder-marks v4) (list "one.txt" "three")) (assert-equal (folder-marks v4) (list "two.txt" "three")) (assert-equal (folder-marks (folder-clear-marks v4)) '())))packages/slate/test/folder-image-relay-browser-live.mjsadded
// Isolated real Enclave + built node + compiled browser image test.// Run with OpenSSL on PATH (guix shell openssl -- node <this-file>), or OPENSSL.// SLATE_IMAGE_FIXTURE selects a local PNG; the default uses generated test bytes.// SLATE_SHORT_WRITE_LIBRARY points at the compiled sigil-websocket test// instrument test/integration/short-socket-writes.c (Linux only).import fs from 'node:fs';import os from 'node:os';import path from 'node:path';import net from 'node:net';import tls from 'node:tls';import crypto from 'node:crypto';import {spawn, spawnSync} from 'node:child_process';const root = path.resolve(import.meta.dirname, '../../..');const enclave = process.env.ENCLAVE_BIN || path.resolve(root, '../enclave/build/dev/bin/enclave');const node = path.resolve(process.env.SLATE_BIN || path.join(root, 'build/node-release/bin/slate'));const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'slate-image-relay-'));const children = [];const relaySockets = new Set();let tlsRelay;const secrets = [];const redact = text => secrets.reduce((s, secret) => s.split(secret).join('[redacted]'), text);const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));const port = () => new Promise(resolve => { const s = net.createServer(); s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); });});function start(executable, args, env) { const child = spawn(executable, args, {cwd:root, env:{...process.env, ...env}, detached:true, stdio:['ignore','pipe','pipe']}); child.output = ''; child.stdout.on('data', data => { child.output += data; }); child.stderr.on('data', data => { child.output += data; }); child.done = new Promise(resolve => { child.once('exit', resolve); child.once('error', e => { child.output += e.message; resolve(-1); }); }); children.push(child); return child;}async function ready(child, marker) { for(let i=0;i<600;i++) { const output = child.output + (child.logFile && fs.existsSync(child.logFile) ? fs.readFileSync(child.logFile,'utf8') : ''); if(new RegExp(marker).test(output)) return; if(child.exitCode !== null) break; await sleep(100); } throw Error('Service failed to become ready: '+redact(child.output));}try { const irc = await port(), ws = await port(); const state = path.join(dir,'state'), db = path.join(dir,'enclave.db'); fs.mkdirSync(path.join(state,'slate'), {recursive:true, mode:0o700}); const credential = crypto.randomBytes(32).toString('hex'); secrets.push(credential); const run = args => { const r = spawnSync(enclave,args,{env:{...process.env,ENCLAVE_DB_PATH:db},encoding:'utf8'}); if(r.status !== 0) throw Error('Enclave setup failed: '+redact(r.stderr)); return r.stdout; }; run(['user','add','gate-owner','--password',credential]); const registration = run(['bot','register','gate-node','--owner','gate-owner']); const token = registration.match(/Token \(one-time display\): ([0-9a-f]{64})/)?.[1]; if(!token) throw Error('Node registration did not return a token'); secrets.push(token); const image = path.join(dir,'preview.png'); const bytes = process.env.SLATE_IMAGE_FIXTURE ? fs.readFileSync(process.env.SLATE_IMAGE_FIXTURE) : crypto.randomBytes(1600000); if(!process.env.SLATE_IMAGE_FIXTURE) Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a4HcAAAAASUVORK5CYII=','base64').copy(bytes); if(bytes.subarray(0,8).toString('hex') !== '89504e470d0a1a0a') throw Error('Image fixture must be PNG'); fs.writeFileSync(image,bytes); const server = start(enclave,['dev'],{ENCLAVE_DB_PATH:db,ENCLAVE_PORT:String(irc),ENCLAVE_WS_PORT:String(ws),ENCLAVE_LOG_LEVEL:'info'}); await ready(server,'WebSocket listening|ws-listener-ready'); // Exercise the node's deployed WSS path. Plain loopback WS cannot catch // TLS buffering, partial writes, or binary corruption in encrypted reads. const certificate = path.join(dir,'cert.pem'); const key = path.join(dir,'key.pem'); const certResult = spawnSync(process.env.OPENSSL || 'openssl',['req','-x509','-newkey','rsa:2048','-nodes', '-keyout',key,'-out',certificate,'-days','1','-subj','/CN=localhost', '-addext','subjectAltName=IP:127.0.0.1,DNS:localhost'],{stdio:'ignore'}); if(certResult.status !== 0) throw Error('Could not create the isolated TLS certificate'); tlsRelay = tls.createServer({key:fs.readFileSync(key),cert:fs.readFileSync(certificate)}, socket => { const upstream = net.connect(ws,'127.0.0.1'); for(const peer of [socket,upstream]) { relaySockets.add(peer); peer.on('close',()=>relaySockets.delete(peer)); peer.on('error',()=>{socket.destroy();upstream.destroy();}); } socket.pipe(upstream).pipe(socket); }); tlsRelay.on('tlsClientError', error => console.log('Test TLS handshake: '+error.message)); await new Promise(resolve=>tlsRelay.listen(0,'127.0.0.1',resolve)); const securePort = tlsRelay.address().port; fs.writeFileSync(path.join(state,'slate','enclave-node.json'),JSON.stringify({version:1,origin:`https://localhost:${securePort}`,'websocket-url':`wss://localhost:${securePort}/ws`,'node-name':'gate-node','bot-id':'1',token}),{mode:0o600}); const logFile = path.join(dir,'node.log'); const shortWriteLog = path.join(dir,'short-writes.log'); const daemon = start(node,['node','run','--app-upstream','','--log-level','debug','--log',logFile],{ XDG_STATE_HOME:state, SLATE_NODE_GRANTS:`fs:ro:${image},pty:on,exec:full`, // The test certificate is private to this disposable loopback relay. // sigil-tls currently prefers system roots over SSL_CERT_FILE. SLATE_MAX_HOLDERS:'1', SIGIL_LOG_LEVEL:'debug', SIGIL_TLS_INSECURE:'1', ...(process.env.SLATE_SHORT_WRITE_LIBRARY ? { LD_PRELOAD:path.resolve(process.env.SLATE_SHORT_WRITE_LIBRARY), SIGIL_SHORT_WRITE_LOG:shortWriteLog } : {}) }); daemon.logFile = logFile; await ready(daemon,'Slate node connected to Enclave as gate-node|enclave session ready'); console.log('Isolated Enclave and node are ready; starting browser regression'); const browser = start(process.execPath,[process.env.SLATE_BROWSER_GATE || 'packages/slate/test/folder-redesign-browser-gate.mjs'],{ P3S_ENCLAVE_WS:`ws://127.0.0.1:${ws}/ws`,P3S_ACCOUNT:'gate-owner',P3S_CREDENTIAL:credential,P3S_NODE:'gate-node',P3S_IMAGE_PATH:image, P3S_STATE_DIR:path.join(state,'slate'), P3S_IMAGE_SIZE:String(bytes.length), P3S_IMAGE_WIDTH:String(bytes.readUInt32BE(16)), P3S_IMAGE_SHA256:crypto.createHash('sha256').update(bytes).digest('hex'), P3S_IMAGE_WAKE_DELAY_MS:process.env.P3S_IMAGE_WAKE_DELAY_MS || '100' }); browser.stdout.on('data', data => process.stdout.write(redact(String(data)))); let deadline; const result = await Promise.race([ browser.done, new Promise(resolve => { deadline = setTimeout(() => resolve('timeout'),300000); }) ]); clearTimeout(deadline); let nodeLogs = daemon.output; for(const name of fs.readdirSync(dir).filter(name => name.startsWith('node.log'))) nodeLogs += fs.readFileSync(path.join(dir,name),'utf8'); if(result !== 0) throw Error('Live browser gate failed: '+result+'\n'+redact(browser.output)+'\n'+redact(nodeLogs)+'\n'+redact(server.output.split('\n').filter(line => /remote-rpc|stream|disconnect|closing|ERROR|WARN/.test(line)).slice(-60).join('\n'))); if(!/max holders reached/.test(nodeLogs)) throw Error('The regression did not exercise an exhausted holder quota'); if(process.env.SLATE_SHORT_WRITE_LIBRARY) { const counts = fs.readFileSync(shortWriteLog,'utf8').trim().split('\n') .map(line=>line.split(' ').map(Number)); const short = counts.reduce((n,row)=>n+row[1],0); const blocked = counts.reduce((n,row)=>n+row[2],0); if(!(short > 0 && blocked > 0)) throw Error('The regression did not force short writes and EAGAIN'); console.log(`PASS TLS backpressure: ${short} short writes, ${blocked} would-block responses`); }} catch(error) { console.error(redact(error.message)); process.exitCode = 1;} finally { for(const child of children.reverse()) { try { process.kill(-child.pid,'SIGTERM'); } catch {} await Promise.race([child.done,sleep(1000)]); try { process.kill(-child.pid,'SIGKILL'); } catch {} } for(const socket of relaySockets) socket.destroy(); if(tlsRelay) await new Promise(resolve=>tlsRelay.close(resolve)); // Terminal owners deliberately survive their node's process group. Only // retire owners whose argv names this test's unique private state directory. for(const entry of fs.readdirSync('/proc').filter(name => /^\d+$/.test(name))) { try { if(fs.readFileSync(`/proc/${entry}/comm`,'utf8').trim() !== path.basename(node)) continue; const args = fs.readFileSync(`/proc/${entry}/cmdline`,'utf8').split('\0'); if(args.includes('_terminal-owner') && args.includes(path.join(dir,'state/slate'))) process.kill(-Number(entry),'SIGTERM'); } catch { /* Processes may exit while inspecting this test's owners. */ } } fs.rmSync(dir,{recursive:true,force:true});}packages/slate/test/folder-redesign-browser-gate.mjsadded
// Drive the folder redesign through the shipped WASM and real browser events.import assert from 'node:assert/strict';import fs from 'node:fs';import http from 'node:http';import os from 'node:os';import path from 'node:path';import { spawn } from 'node:child_process';const root = path.resolve(import.meta.dirname, '../../..');const webRoot = path.resolve(process.env.SLATE_WEB_ROOT || path.join(root, 'build/web'));assert.ok(fs.existsSync(path.join(webRoot, 'slate.wasm')), 'build/web/slate.wasm is required');const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));const types = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.wasm': 'application/wasm', '.json': 'application/json', '.svg': 'image/svg+xml', '.woff2': 'font/woff2' };const server = http.createServer((req, res) => { const relative = req.url.split('?')[0] === '/' ? 'index.html' : req.url.split('?')[0].slice(1); const target = path.resolve(webRoot, relative); if (!target.startsWith(webRoot + path.sep) || !fs.existsSync(target)) { res.writeHead(404).end(); return; } res.setHeader('content-type', types[path.extname(target)] || 'application/octet-stream'); fs.createReadStream(target).pipe(res);});await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'slate-folder-redesign-chrome-'));const chrome = spawn('google-chrome', ['--headless=new', '--no-sandbox', '--disable-gpu', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'],{ stdio: ['ignore', 'ignore', 'pipe'] });const chromeExit = new Promise(resolve => chrome.once('exit', resolve));let socket;const errors = [];const consoleMessages = [];try { const activePort = path.join(profile, 'DevToolsActivePort'); for (let i = 0; i < 120 && !fs.existsSync(activePort); i++) await sleep(50); assert.ok(fs.existsSync(activePort), 'Chrome did not expose DevTools'); const [debugPort, browserPath] = fs.readFileSync(activePort, 'utf8').trim().split('\n'); socket = new WebSocket(`ws://127.0.0.1:${debugPort}${browserPath}`); await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; }); let nextId = 0; const pending = new Map(); socket.onmessage = event => { const message = JSON.parse(event.data); if (message.method === 'Runtime.exceptionThrown') errors.push(message.params.exceptionDetails.exception?.description || message.params.exceptionDetails.text); if (message.method === 'Runtime.consoleAPICalled') consoleMessages.push(message.params.args.map(arg => arg.value ?? arg.description).join(' ')); if (message.method === 'Log.entryAdded') consoleMessages.push(message.params.entry.text); if (message.id && pending.has(message.id)) { const pair = pending.get(message.id); pending.delete(message.id); message.error ? pair.reject(new Error(message.error.message)) : pair.resolve(message.result); } }; const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => { const id = ++nextId; pending.set(id, { resolve, reject }); socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); }); const target = await send('Target.createTarget', { url: 'about:blank' }); const attached = await send('Target.attachToTarget', { targetId: target.targetId, flatten: true }); const session = attached.sessionId; await send('Runtime.enable', {}, session); await send('Log.enable', {}, session); await send('Emulation.setEmulatedMedia', {features:[{name:'prefers-color-scheme',value:'dark'}]}, session); await send('Page.navigate', { url: `http://127.0.0.1:${server.address().port}/?rbdebug&folder-redesign-gate` }, session); const evaluate = async expression => { const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, session); if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text); return result.result.value; }; async function waitFor(expression, label, timeoutMs = 36000) { for (let i = 0; i < Math.ceil(timeoutMs / 100); i++) { if (await evaluate(expression)) return; await sleep(100); } const status = await evaluate('document.getElementById("status")?.textContent'); const preview = await evaluate('document.querySelector(".folder-preview")?.textContent'); throw new Error(`timeout waiting for ${label}; status=${status}; preview=${preview}; errors=${errors.join(' | ')}; console=${consoleMessages.slice(-10).join(' | ')}`); } const sgl = code => evaluate('(() => { const result = window.slateEval(' + JSON.stringify(code) + '); window.slateAfterRender(); return result; })()'); const dispatch = event => sgl('(slate-dispatch "' + event + '")'); const readCount = () => sgl('(number->string (length folder-gate-reads))').then(Number); await waitFor(`document.getElementById('status')?.textContent === 'ready'`, 'Slate boot'); await waitFor('window.__slateUnveilReason !== undefined', 'initial workspace restoration'); await waitFor(`window.slateEval('(slate-preferences-settled?)') === '1'`, 'startup preferences'); const now = Math.floor(Date.now()/1000); await send('Emulation.setDeviceMetricsOverride', {width:1440,height:1000,deviceScaleFactor:1,mobile:false}, session); await sgl('(import (slate provider) (slate root) (slate command) (slate state) (slate modes folder))'); await sgl('(define folder-gate-reads (list))'); await sgl('(define folder-gate-git (list))'); await sgl('(define folder-gate-images (list))'); await sgl('(define folder-gate-released (list))'); await sgl(`(register-provider! folder-gate: #{ materialize: (lambda (source) (if (eq? (dict-ref source kind: file:) dir:) (list) "")) image-url: (lambda (source ok error) (set! folder-gate-images (append folder-gate-images (list ok)))) image-release: (lambda (source url) (set! folder-gate-released (cons url folder-gate-released))) materialize-async: (lambda (source ok error) (set! folder-gate-reads (append folder-gate-reads (list (cons source ok))))) git-request: (lambda (source verb args ok error) (set! folder-gate-git (append folder-gate-git (list (list verb args ok))))) })`); await sgl('(register-root! folder-gate: #{ provider: folder-gate: class: local: home: "/repo" host: "slate-node-opaque" label: "zerocool" })'); await dispatch('openroot:folder-gate'); assert.equal(await sgl('(number->string (length folder-gate-git))'), '0', 'Git waits for listing'); await sgl(`((cdr (car folder-gate-reads)) (list #{ name: "src" kind: dir: mtime: ${now-300} } #{ name: "alpha.sgl" kind: file: size: 20 mtime: ${now-120} } #{ name: "beta.sgl" kind: file: size: 15 mtime: ${now-60} } #{ name: ".hidden" kind: file: size: 2 mtime: ${now-600} }))`); await waitFor('document.querySelectorAll(".folder-row").length === 3', 'directory listing'); assert.equal(await sgl('(number->string (length folder-gate-git))'), '1'); assert.equal(await evaluate('document.querySelector(".folder-host").textContent'), 'zerocool'); assert.equal(await evaluate('Boolean(document.querySelector(".folder-preview"))'), false, 'preview defaults off'); await dispatch('folder-select-index:1'); await dispatch('folder-mark-index:2'); await evaluate('window.folderGateRow=document.querySelector(".folder-row.selected");'); await sgl(`((caddr (car folder-gate-git)) #{ version: 1 node-repo: #{ worktree: "/repo" common-dir: "/repo/.git" } })`); await sgl(`((caddr (cadr folder-gate-git)) #{ version: 1 repo: (dict-ref (cadr (cadr folder-gate-git)) repo:) snapshot: #{ head-oid: "abc" index-id: "i" worktree-id: "w" } entries: (list #{ path: "alpha.sgl" kind: "ordinary" index-state: "." worktree-state: "M" }) })`); assert.equal(await evaluate('document.querySelector(".folder-row.selected")===window.folderGateRow'),true, 'Git retains the keyed row: '+JSON.stringify(await evaluate('({before:window.folderGateRow?.outerHTML,after:document.querySelector(".folder-row.selected")?.outerHTML,body:document.getElementById("app")?.textContent})'))+'; console='+consoleMessages.slice(-10).join(' | ')); assert.equal(await evaluate('document.querySelector(".folder-row.selected .folder-git")?.textContent'),'M', 'Git marker: '+await evaluate('document.getElementById("app").textContent')+'; '+consoleMessages.slice(-10).join(' | ')); await dispatch('folder-preview'); assert.equal(await readCount(), 1, 'no immediate preview read'); await waitFor(`window.slateEval('(number->string (length folder-gate-reads))') === '2'`, 'delayed preview read'); await sgl('((cdr (cadr folder-gate-reads)) "(define alpha 42)\n")'); await waitFor('document.querySelector(".folder-preview-content")?.textContent.includes("define alpha")', 'preview content'); const pressPreviewKey = async () => { await send('Input.dispatchKeyEvent', {type:'keyDown',key:'P',code:'KeyP',modifiers:8,text:'P'}, session); await send('Input.dispatchKeyEvent', {type:'keyUp',key:'P',code:'KeyP',modifiers:8}, session); }; await pressPreviewKey(); assert.equal(await evaluate('document.querySelector(".folder-preview")'),null,'capital P closes preview'); await pressPreviewKey(); assert.equal(await evaluate('Boolean(document.querySelector(".folder-preview"))'),true,'capital P opens preview'); await waitFor(`window.slateEval('(number->string (length folder-gate-reads))') === '3'`, 'reopened preview read'); await sgl('((cdr (car (reverse folder-gate-reads))) "(define alpha 42)")'); assert.equal(await evaluate('document.querySelector(".view.folder .acc-row, .view.folder .command-strip, .folder-preview .preview-open")'),null); await dispatch('folder-copy'); await sgl(`(install-builtin-command! "folder-gate-prompt" "Prompt state" "Test fixture" '(test folder) (lambda (state) (with-echo state (dict-ref state slate/minibuffer: ""))))`); assert.equal(await dispatch('folder-gate-prompt'),'folder-input'); await dispatch('selector-escape'); await dispatch('folder-rename'); assert.equal(await dispatch('folder-gate-prompt'),'folder-input'); await dispatch('selector-escape'); await dispatch('folder-sort-newest'); assert.equal(await evaluate('[...document.querySelectorAll(".folder-name")].map(n=>n.textContent).join(",")'),'src/,beta.sgl,alpha.sgl'); await dispatch('folder-sort-oldest'); assert.equal(await evaluate('[...document.querySelectorAll(".folder-name")].map(n=>n.textContent).join(",")'),'src/,alpha.sgl,beta.sgl'); await dispatch('folder-hidden'); assert.equal(await evaluate('document.querySelectorAll(".folder-row").length'),4); await dispatch('folder-hidden'); await sgl(`(install-builtin-command! "folder-gate-screenshot" "Screenshot directory" "Test fixture" '(test folder) (lambda (state) (update-active state (lambda (v) (folder-set-sort (folder-set-entries v (append (vector->list (folder-all-entries v)) (list #{ name: "assets" kind: dir: mtime: ${now-7200} } #{ name: "docs" kind: dir: mtime: ${now-86400} } #{ name: "test" kind: dir: mtime: ${now-3600} } #{ name: "README.md" kind: file: size: 4896 mtime: ${now-172800} } #{ name: "package.sgl" kind: file: size: 2860 mtime: ${now-10800} } #{ name: "sigil.lock" kind: file: size: 12890 mtime: ${now-86400} } #{ name: "workspace-notes.md" kind: file: size: 1456 mtime: ${now-600} }))) name:)))))`); const beforeScreenshot = await readCount(); await dispatch('folder-gate-screenshot'); await waitFor(`Number(window.slateEval('(number->string (length folder-gate-reads))')) > ${beforeScreenshot}`, 'screenshot preview read'); await sgl('((cdr (car (reverse folder-gate-reads))) "(define-library (project alpha)\\n (export greet)\\n (begin\\n (define (greet name)\\n (string-append \\"Hello, \\" name))))\\n")'); await send('Page.enable',{},session); const screenshot=await send('Page.captureScreenshot',{format:'png'},session); fs.writeFileSync('/tmp/slate-folder-implemented-desktop.png',Buffer.from(screenshot.data,'base64')); await send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true},session); await send('Emulation.setTouchEmulationEnabled',{enabled:true,maxTouchPoints:1},session); await dispatch('folder-preview'); assert.equal(await evaluate('document.documentElement.scrollWidth <= innerWidth'),true,'mobile page width'); assert.ok(await evaluate('document.querySelector(".folder-header").getBoundingClientRect().height <= 46'),'compact mobile header'); const mobile=await send('Page.captureScreenshot',{format:'png'},session); fs.writeFileSync('/tmp/slate-folder-implemented-mobile.png',Buffer.from(mobile.data,'base64')); await sgl(`(install-builtin-command! "folder-gate-large" "Large directory" "Test fixture" '(test folder) (lambda (state) (update-active state (lambda (v) (folder-set-entries v (let loop ((i 0) (out (list))) (if (= i 600) out (loop (+ i 1) (cons #{ name: (string-append "file-" (number->string i)) kind: file: size: 1 } out)))))))))`); await dispatch('folder-gate-large'); await dispatch('folder-begin'); const motion = await evaluate(`(() => { const view = document.querySelector('.view.folder'); const render = view.dataset.folderRender; const times = []; for (let i = 0; i < 40; i++) { const start = performance.now(); window.slateEval('(slate-dispatch "folder-next")'); window.slateAfterRender(); times.push(performance.now() - start); } times.sort((a,b) => a-b); return {median:times[20], p95:times[38], total:times.reduce((a,b)=>a+b,0), sameRender:render === view.dataset.folderRender, selected:view.querySelector('.folder-row.selected')?.getAttribute('data-slate-click')}; })()`); assert.equal(motion.sameRender, true, 'point motion does not rebuild the listing'); assert.equal(motion.selected, 'folder-select-index:40'); console.log('FOLDER MOTION '+JSON.stringify(motion)); await evaluate('window.slateRefresh()'); assert.equal(await evaluate('document.querySelector(".folder-row.selected").getAttribute("data-slate-click")'), 'folder-select-index:40', 'a later full render preserves point'); await send('Input.dispatchKeyEvent', {type:'keyDown',key:'p',code:'KeyP',text:'p'}, session); await send('Input.dispatchKeyEvent', {type:'keyUp',key:'p',code:'KeyP'}, session); await waitFor('document.querySelector(".folder-row.selected")?.getAttribute("data-slate-click") === "folder-select-index:39"', 'real p key'); const beforeBurst = await readCount(); await dispatch('folder-preview'); await evaluate(`(() => { for (let i=0; i<30; i++) { window.slateEval('(slate-dispatch "folder-next")'); window.slateAfterRender(); } })()`); await sleep(250); assert.equal(await readCount(), beforeBurst, 'movement postpones the preview read'); await waitFor(`Number(window.slateEval('(number->string (length folder-gate-reads))')) === ${beforeBurst+1}`, 'one settled preview request'); await evaluate(`(() => { for (let i=0; i<20; i++) { window.slateEval('(slate-dispatch "folder-prev")'); window.slateAfterRender(); } })()`); await sleep(650); assert.equal(await readCount(), beforeBurst+1, 'slow preview does not accumulate requests'); await sgl('((cdr (car (reverse folder-gate-reads))) "obsolete contents")'); await waitFor(`Number(window.slateEval('(number->string (length folder-gate-reads))')) === ${beforeBurst+2}`, 'latest point after old request completes'); await sgl('((cdr (car (reverse folder-gate-reads))) "latest contents")'); assert.equal(await evaluate('document.querySelector(".folder-preview-content").textContent'), 'latest contents'); await dispatch('folder-preview'); await dispatch('folder-begin'); let rounds=0; while(await evaluate('[...document.querySelectorAll(".folder-more")].some(n=>n.textContent.includes("below"))')) { await evaluate('[...document.querySelectorAll(".folder-more")].find(n=>n.textContent.includes("below")).click()'); if(++rounds>10)throw Error('continuation failed to advance'); } assert.ok(rounds>0,'clicked continuation'); assert.ok(await evaluate('document.querySelectorAll(".folder-row").length <= 201'),'bounded rendering'); await evaluate('[...document.querySelectorAll(".folder-more")].find(n=>n.textContent.includes("above")).click()'); assert.equal(await evaluate('[...document.querySelectorAll(".folder-more")].some(n=>n.textContent.includes("below"))'),true); await sgl(`(install-builtin-command! "folder-gate-image-directory" "Image directory" "Test fixture" '(test folder) (lambda (state) (update-active state (lambda (v) (folder-set-entries v (list #{ name: "child" kind: dir: } #{ name: "photo.PNG" kind: file: size: 900000 } #{ name: "z.sgl" kind: file: size: 10 }))))))`); await dispatch('folder-gate-image-directory'); await dispatch('folder-select-index:1'); const beforeImage = await readCount(); await dispatch('folder-preview'); await waitFor(`window.slateEval('(number->string (length folder-gate-images))') === '1'`, 'image URL request'); assert.equal(await readCount(), beforeImage, 'images bypass text materialization'); const imageUrl = 'data:image/svg+xml,'+encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="96" height="64"><rect width="96" height="64" fill="#dba957"/></svg>'); await sgl('((car folder-gate-images) '+JSON.stringify(imageUrl)+')'); await waitFor('document.querySelector(".folder-preview img")?.naturalWidth === 96', 'decoded image preview'); await dispatch('folder-preview'); assert.equal(await sgl('(number->string (length folder-gate-released))'), '1', 'preview image URL released on close'); await dispatch('folder-select-index:0'); await dispatch('folder-preview'); await dispatch('folder-open'); assert.equal(await evaluate('Boolean(document.querySelector(".folder-preview"))'), true, 'preview stays open on descent'); await sgl('((cdr (car (reverse folder-gate-reads))) (list #{ name: "leaf.sgl" kind: file: size: 10 }))'); await dispatch('folder-parent'); assert.equal(await evaluate('Boolean(document.querySelector(".folder-preview"))'), true, 'preview stays open in parent'); await dispatch('folder-preview'); // Drive the actual remote provider through its captured transport, then let // the normal browser tick turn the binary reply into a decoded image. await sgl('(import (sigil json) (sigil io) (slate view) (slate providers node) (slate providers enclave-node) (slate providers enclave-node carrier))'); await sgl('(node-reset!)'); await sgl('(node-set-transport!)'); await sgl('(node-set-token! "fixture-token")'); const deliver = async message => sgl('(node-deliver-text! '+JSON.stringify(JSON.stringify(message))+')'); await deliver({t:'hello',instance:'fixture-local'}); await deliver({t:'auth-ok',grants:[]}); await sgl('(enclave-node-reset!)'); await sgl('(enclave-node-register!)'); await sgl('(node-clear-sent!)'); await sgl('(register-root! remote-image-gate: #{provider: node: host: "fixture-remote" label: "zerocool"})'); await sgl(`(install-builtin-command! "folder-gate-real-image" "Remote image" "Fixture" '(test folder) (lambda (state) (update-active state (lambda (old) (folder-toggle-preview (folder-set-entries (make-folder-view (view-name old) #{provider: node: host: "fixture-remote" kind: dir: address: "/photos"}) (list #{name: "preview.SVG" kind: file: size: 200})))))))`); await dispatch('folder-gate-real-image'); const sent = async () => JSON.parse(await sgl('(json-encode (node-sent-text))')).map(JSON.parse); const open = (await sent()).find(m=>m.verb==='remote/open'); assert.ok(open, 'real provider acquires a remote carrier'); await deliver({t:'ev',event:'remote/opened',carrier:91,request:open.id,transport:'direct'}); const innerMessages = async () => (await sent()).filter(m=>m.t==='remote/text').map(m=>JSON.parse(m.payload)); const discovery = (await innerMessages()).find(m=>m.verb==='git/discover'); assert.ok(discovery,'Git discovery uses the real provider'); await deliver({t:'remote/text',carrier:91,payload:JSON.stringify({t:'err',id:discovery.id,code:'not-a-repository',message:'not a Git repository'})}); assert.equal(await evaluate('document.querySelector(".folder-header").textContent.includes("Git unavailable")'),false); assert.equal(await evaluate('document.querySelector(".view.folder").classList.contains("with-git")'),false,'non-repository has no Git column'); let request; for(let i=0;i<50&&!request;i++){ request=(await innerMessages()).find(m=>m.verb==='fs/read-file'); if(!request) await sleep(100); } assert.ok(request,'real provider requests the image after debounce'); assert.equal(request.args.path,'/photos/preview.SVG'); assert.equal(request.args.transfer,true); const svg='<svg xmlns="http://www.w3.org/2000/svg" width="73" height="41"><rect width="73" height="41" fill="#dba957"/></svg>'; await deliver({t:'remote/text',carrier:91,payload:JSON.stringify({t:'ok',id:request.id,transfer:{direction:'download',size:svg.length,'chunk-bytes':65536}})}); await sgl('(node-deliver-binary! (carrier-frame-encode 91 (frame-encode 32 '+request.id+' (bytevector-append (bytevector 0 0 0 0) (string->utf8 '+JSON.stringify(svg)+')))))'); await sgl('(node-deliver-binary! (carrier-frame-encode 91 (frame-encode 33 '+request.id+' (bytevector 0 0 0 1 0 0 0 '+svg.length+'))))'); await waitFor('document.querySelector(".folder-preview img")?.naturalWidth === 73','real remote image decoded from a Blob'); assert.match(await evaluate('document.querySelector(".folder-preview img").src'),/^blob:/); const remoteShot = await send('Page.captureScreenshot', {format:'png'}, session); fs.writeFileSync('/tmp/slate-folder-remote-image.png', Buffer.from(remoteShot.data, 'base64')); await dispatch('folder-preview'); await sgl('(node-reset!)'); if (process.env.P3S_IMAGE_PATH) { const wakeDelay = Number(process.env.P3S_IMAGE_WAKE_DELAY_MS || 0); if(wakeDelay > 0) { // Delay both socket wakes and async browser-bridge resumptions, as on a // busy client. Keep the normal polling timer active throughout. await evaluate(`window.queueMicrotask = callback => setTimeout(callback, ${wakeDelay})`); } // Optional live gate: authenticate a disposable browser against real // Enclave and an installed node, with NO local companion connection. await sgl('(import (slate enclave-account session))'); await sgl('(enclave-node-reset!)'); await sgl('(define folder-relay-ready #f)'); await sgl('(define folder-relay-error #f)'); await sgl(`(account-session-start-saved! #{version: 1 url: ${JSON.stringify(process.env.P3S_ENCLAVE_WS)} nick: ${JSON.stringify(process.env.P3S_ACCOUNT)} credential: ${JSON.stringify(process.env.P3S_CREDENTIAL)}} (lambda () (account-session-on-message! enclave-node-on-message!) (enclave-node-register!) (set! folder-relay-ready #t)) (lambda (reason) (set! folder-relay-error reason)))`); await waitFor(`window.slateEval('(if folder-relay-ready "ready" "waiting")') === 'ready'`, 'real Enclave authentication'); assert.equal(await sgl('(if (node-ready?) "companion" "browser-only")'), 'browser-only'); await sgl('(define folder-relay-image-errors (list))'); await sgl(`(let* ((provider (provider-for #{provider: node:})) (image-op (dict-ref provider image-url: #f))) (register-provider! node: (dict-set provider image-url: (lambda (source ready failed) (image-op source ready (lambda (reason) (set! folder-relay-image-errors (cons reason folder-relay-image-errors)) (failed reason)))))))`); // Occupy the single holder with a live terminal, then prove another // terminal is refused before testing resource transfers independently. await sgl('(define folder-relay-output "")'); await sgl('(define folder-relay-events (list))'); await sgl(`(define folder-relay-terminal (source-session-open #{provider: node: kind: pty: host: ${JSON.stringify(process.env.P3S_NODE)} argv: (list "sh" "-c" "printf READY; cat") cols: 80 rows: 24} (lambda (event) (set! folder-relay-events (cons event folder-relay-events)) (when (dict-ref event bytes: #f) (set! folder-relay-output (string-append folder-relay-output (dict-ref event bytes: ""))))) #{window: 65536}))`); try { await waitFor(`window.slateEval('folder-relay-output').includes('READY')`, 'live terminal occupying the holder quota'); } catch(error) { throw Error(error.message + '; terminal events=' + await sgl('(json-encode folder-relay-events)')); } console.log('PASS real relay terminal opened'); await sgl('(define folder-relay-refused #f)'); await sgl(`(source-session-open #{provider: node: kind: pty: host: ${JSON.stringify(process.env.P3S_NODE)} argv: (list "sh") cols: 80 rows: 24} (lambda (event) (when (dict-ref event error: #f) (set! folder-relay-refused #t))) #{window: 65536})`); await waitFor(`window.slateEval('(if folder-relay-refused "refused" "waiting")') === 'refused'`, 'session quota refusal'); const remotePath = process.env.P3S_IMAGE_PATH; const imageSize = Number(process.env.P3S_IMAGE_SIZE || 1600000); const imageWidth = Number(process.env.P3S_IMAGE_WIDTH || 1); const previewAttempts = Number(process.env.P3S_IMAGE_ATTEMPTS || 10); assert.ok(Number.isInteger(previewAttempts) && previewAttempts > 0 && previewAttempts <= 20); const imageTimeout = imageSize > 4194304 ? 120000 : 36000; async function verifyImage(selector) { const result = await evaluate(`(async () => { const bytes = await (await fetch(document.querySelector(${JSON.stringify(selector)}).src)).arrayBuffer(); const hash = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))) .map(byte => byte.toString(16).padStart(2, '0')).join(''); return {size: bytes.byteLength, hash}; })()`); assert.equal(result.size, imageSize); if(process.env.P3S_IMAGE_SHA256) assert.equal(result.hash, process.env.P3S_IMAGE_SHA256, 'image bytes remain intact'); } await sgl(`(install-builtin-command! "folder-gate-live-relay" "Live relay" "Fixture" '(test folder) (lambda (state) (update-active state (lambda (old) (folder-toggle-preview (folder-set-entries (make-folder-view (view-name old) #{provider: node: host: ${JSON.stringify(process.env.P3S_NODE)} kind: dir: address: ${JSON.stringify(path.dirname(remotePath))}}) (list #{name: ${JSON.stringify(path.basename(remotePath))} kind: file: size: ${imageSize}})))))))`); const previewUrls = new Set(); for(let attempt=0;attempt<previewAttempts;attempt++) { await dispatch('folder-gate-live-relay'); try { await waitFor(`document.querySelector(".folder-preview img")?.naturalWidth === ${imageWidth}`, `image preview ${attempt+1} with full holder quota`, imageTimeout); } catch(error) { throw Error(error.message + '; image errors=' + await sgl('(json-encode folder-relay-image-errors)') + '; account phase=' + await sgl('(symbol->string (account-session-phase))')); } console.log(`PASS image preview ${attempt+1}`); previewUrls.add(await evaluate('document.querySelector(".folder-preview img").src')); await verifyImage('.folder-preview img'); await dispatch('folder-preview'); if(process.env.P3S_STATE_DIR) { const resources = path.join(process.env.P3S_STATE_DIR,'resources'); const remaining = () => fs.existsSync(resources) ? fs.readdirSync(resources).filter(name => name.endsWith('.sock')) : []; for(let wait=0;wait<50 && remaining().length;wait++) await sleep(100); assert.deepEqual(remaining(), [], 'completed image transfer retires its worker'); } } assert.equal(previewUrls.size, previewAttempts, 'each preview completed a fresh binary transfer'); await send('Input.dispatchKeyEvent', {type:'keyDown',key:'Enter',code:'Enter',windowsVirtualKeyCode:13}, session); await send('Input.dispatchKeyEvent', {type:'keyUp',key:'Enter',code:'Enter',windowsVirtualKeyCode:13}, session); await waitFor(`document.querySelector(".image-img")?.naturalWidth === ${imageWidth}`, 'Enter opens image with full holder quota', imageTimeout); await verifyImage('.image-img'); await sgl('(session-write folder-relay-terminal "STILL-ALIVE\\n")'); await waitFor(`window.slateEval('folder-relay-output').includes('STILL-ALIVE')`, 'existing terminal survives all image transfers'); await sgl('(session-close folder-relay-terminal)'); await sgl('(account-session-close!)'); console.log(`PASS live relay: full holder quota, ${previewAttempts} ${imageSize}-byte previews, Enter image decoding, existing terminal remains responsive`); } assert.deepEqual(errors, [], 'runtime errors'); console.log('PASS folder browser: fast point motion, debounced bounded previews, real remote image decoding, silent non-repository state, image release, registered names, preview persistence, async listing/Git/preview, keyed DOM continuity, marks, existing copy/move prompts, sorting, hidden, responsive layout, clickable continuations');} finally { if (socket) socket.close(); chrome.kill('SIGTERM'); await Promise.race([chromeExit, sleep(2000)]); await new Promise(resolve => server.close(resolve)); try { fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch (_) { /* Chrome helpers may finish touching the disposable profile late. */ }}packages/slate/test/folder-redesign-test.sgladded
(import (sigil test) (sigil string) (slate command) (slate commands folder) (slate folder model) (slate folder enrichment) (slate modes folder) (slate option) (slate pane) (slate provider) (slate root) (slate space) (slate state) (slate view))(define listing (list #{ name: "old.txt" kind: file: size: 2 mtime: 10 } #{ name: "new.txt" kind: file: size: 4 mtime: 30 } #{ name: "unknown.txt" kind: file: size: 1 } #{ name: "src" kind: dir: mtime: 20 } #{ name: "assets" kind: dir: mtime: 5 } #{ name: ".hidden" kind: file: size: 2 mtime: 15 } #{ name: "private" kind: file: hidden: #t size: 1 }))(register-provider! folder-redesign: #{ materialize: (lambda (source) (if (eq? (dict-ref source kind: file:) dir:) listing "hello")) })(define (fixture) (make-folder-view "directory" #{ provider: folder-redesign: kind: dir: address: "/repo" }))(define (state-for v) #{ slate/views: (list v) slate/spaces: (list (make-space "main" (make-leaf 0 (view-name v)) 0 1)) slate/active-space: "main" })(define (names v) (map (lambda (e) (dict-ref e name:)) (vector->list (folder-entries v))))(test "sorting keeps folders first, missing times last, and point by name" (let* ((v (folder-set-point (fixture) 3)) (newest (folder-set-sort v newest:)) (oldest (folder-set-sort v oldest:))) (assert-equal (names newest) '("src" "assets" "new.txt" "old.txt" "unknown.txt")) (assert-equal (names oldest) '("assets" "src" "old.txt" "new.txt" "unknown.txt")) (assert-equal (dict-ref (folder-entry-at newest) name:) "old.txt") (assert-equal (dict-ref (folder-entry-at oldest) name:) "old.txt")))(test "marks on hidden entries remain mutation targets and survive refresh" (let* ((all (folder-toggle-hidden (fixture))) (marked (folder-mark-matching all ".*")) (hidden (folder-toggle-hidden marked)) (refreshed (folder-set-entries hidden listing))) (assert-equal (folder-marks refreshed) '(".hidden")) (assert-equal (map (lambda (e) (dict-ref e name:)) (folder-operation-entries refreshed)) '(".hidden")) (assert-false (member "private" (names refreshed)))))(define (actions tree) (cond ((not (pair? tree)) '()) ((and (eq? (car tree) 'data-slate-click) (pair? (cdr tree))) (list (cadr tree))) (else (append (actions (car tree)) (actions (cdr tree))))))(test "continuations are clickable, reach the end, and keep rendering bounded" (let* ((large (let loop ((i 0) (out '())) (if (= i 600) out (loop (+ i 1) (cons #{ name: (string-append "file-" (number->string i)) kind: file: size: 1 } out))))) (v (folder-set-entries (fixture) large)) (first-actions (actions (view-render v))) (next (active-view (command-run (state-for v) "folder-select-index:101"))) (last (active-view (command-run (state-for next) "folder-select-index:599")))) (assert-true (member "folder-select-index:101" first-actions)) (assert-equal (folder-point next) 101) (assert-equal (folder-point last) 599) (assert-true (member "folder-select-index:399" (actions (view-render last)))) (assert-true (< (length (filter (lambda (x) (string-starts-with? x "folder-open-index:")) (actions (view-render last)))) 202))))(test "Git overlay maps immediate children and aggregates conflicts in directories" (let ((overlay (folder-git-overlay "/repo/src" #{ repo: #{ worktree: "/repo" } entries: (list #{ path: "src/a.sgl" kind: "ordinary" index-state: "M" worktree-state: "." } #{ path: "src/lib/a.sgl" kind: "untracked" } #{ path: "src/lib/b.sgl" kind: "conflict" } #{ path: "src-other/wrong.sgl" kind: "untracked" }) }))) (assert-equal (dict-ref (dict-ref overlay "a.sgl") label:) "M") (assert-equal (dict-ref (dict-ref overlay "lib") label:) "!") (assert-false (dict-ref overlay "wrong.sgl" #f))))(test "preview coalesces motion, debounces reads, and bounds in-flight work" (let ((held '()) (notifications 0)) (register-provider! preview-held: #{ materialize: (lambda (source) '()) materialize-async: (lambda (source ok error) (set! held (append held (list ok)))) }) (let* ((v (folder-set-entries (make-folder-view "held" #{ provider: preview-held: kind: dir: address: "/" }) (list #{ name: "a" kind: file: size: 1 } #{ name: "b" kind: file: size: 1 }))) (notify (lambda () (set! notifications (+ notifications 1))))) (refresh-folder-enrichment! (state-for v) 100 notify) (assert-equal (folder-preview-request-token) "") (set! v (folder-toggle-preview v)) (refresh-folder-enrichment! (state-for v) 100 notify) (assert-equal held '()) (let ((obsolete (folder-preview-request-token))) (set! v (folder-set-point v 1)) (refresh-folder-enrichment! (state-for v) 100 notify) (flush-folder-preview! obsolete) (assert-equal held '())) (flush-folder-preview! (folder-preview-request-token)) (assert-equal (length held) 1) (set! v (folder-set-point v 0)) (refresh-folder-enrichment! (state-for v) 100 notify) (assert-equal (folder-preview-request-token) "") ((car held) "obsolete B") (assert-equal (folder-detail v preview-text: #f) "Waiting…") (flush-folder-preview! (folder-preview-request-token)) (assert-equal (length held) 2) ((cadr held) "current A") (assert-equal (folder-detail v preview-text: #f) "current A") (assert-equal notifications 2) (set! v (folder-toggle-preview v)) (refresh-folder-enrichment! (state-for v) 100 notify) (assert-equal (folder-preview-request-token) ""))))(test "image previews use image URLs and release current and obsolete results" (let ((image-ready '()) (reads 0) (released '())) (register-provider! preview-images: #{ materialize: (lambda (source) '()) materialize-async: (lambda (source ok error) (set! reads (+ reads 1))) image-url: (lambda (source ok error) (set! image-ready (append image-ready (list ok)))) image-release: (lambda (source url) (set! released (cons url released))) }) (let* ((v (folder-toggle-preview (folder-set-entries (make-folder-view "images" #{ provider: preview-images: kind: dir: address: "/" }) (list #{ name: "a.PNG" kind: file: size: 900000 } #{ name: "b.jpg" kind: file: size: 500000 })))) (refresh (lambda () (refresh-folder-enrichment! (state-for v) 100 (lambda () #f))))) (refresh) (flush-folder-preview! (folder-preview-request-token)) ((car image-ready) "blob:first") (assert-equal (folder-detail v preview-image-url: #f) "blob:first") (assert-equal reads 0) (set! v (folder-set-point v 1)) (refresh) (assert-equal released '("blob:first")) (flush-folder-preview! (folder-preview-request-token)) (set! v (folder-toggle-preview v)) (refresh) ((cadr image-ready) "blob:obsolete") (assert-equal released '("blob:obsolete" "blob:first")) (assert-false (folder-detail v preview-image-url: #f)) (assert-equal reads 0))))(test "point-only changes reuse the rendered window without accepting content changes" (let* ((v (fixture)) (tree (view-render v)) (next (folder-move v 1))) (assert-true (folder-point-only-change? v next)) (assert-true (folder-point-rendered? next)) (assert-false (folder-point-only-change? v (folder-mark v))) (assert-false (folder-point-only-change? v (folder-toggle-preview v))) (assert-false (folder-point-only-change? v (folder-set-entries v listing)))))(test "folder header uses the root's registered display name" (register-root! registered-test: #{ provider: folder-redesign: host: "slate-node-opaque" label: "zerocool" }) (let* ((v (make-folder-view "remote" #{ provider: folder-redesign: host: "slate-node-opaque" kind: dir: address: "/repo" })) (tree (view-render v))) (define (contains? x text) (cond ((string? x) (string=? x text)) ((pair? x) (or (contains? (car x) text) (contains? (cdr x) text))) (else #f))) (assert-true (contains? tree "zerocool")) (assert-false (contains? tree "slate-node-opaque"))) (unregister-root! registered-test:))(test "new folder previews default off and honor the declared option" (assert-false (dict-ref (fixture) slate/folder-preview?: #t)) (let ((previous-global (set-option-live-global-reader! (lambda (name) (eq? name slate/folder-preview:)))) (previous (set-option-live-reader! (lambda (name default) (if (eq? name slate/folder-preview:) #t default))))) (assert-true (dict-ref (fixture) slate/folder-preview?: #f)) (set-option-live-global-reader! previous-global) (set-option-live-reader! previous)))(test "preview excerpts are bounded and binary-safe" (assert-equal (folder-preview-excerpt (string-append "a" (string (integer->char 0)) "b")) "Binary file") (assert-true (< (string-length (folder-preview-excerpt (make-string 20000 #\a))) 12010)))(test "Git errors do not add a misleading folder header warning" (let ((v (fixture))) (folder-detail-set! v git-capable?: #t) (folder-detail-set! v git-state: #{ state: error: error: #{ code: "not-a-repository" } }) (define (contains-warning? x) (cond ((string? x) (string-contains? x "Git unavailable")) ((pair? x) (or (contains-warning? (car x)) (contains-warning? (cdr x)))) (else #f))) (assert-false (contains-warning? (view-render v)))))packages/slate/test/git-branch-browser-gate.mjsadded
import assert from 'node:assert/strict';import fs from 'node:fs';import path from 'node:path';export async function branchGate({tab,send,sgl,evalIn,waitFor,key,sentFrames,runGit, branchListReleaseFile,branchSwitchReleaseFile,gateRoot}) { const count=verb=>sentFrames.filter(f=>f.includes(verb)).length; const branch=()=>runGit(['symbolic-ref','--short','HEAD']).trim(); const click=async selector=>{ const p=await evalIn(tab,`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e.scrollIntoView({block:'nearest'});const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`); await send('Input.dispatchMouseEvent',{type:'mousePressed',button:'left',clickCount:1,...p},tab.sessionId); await send('Input.dispatchMouseEvent',{type:'mouseReleased',button:'left',clickCount:1,...p},tab.sessionId); }; const type=async text=>{ await waitFor(tab,`!!document.querySelector('.pal-input')`,'branch input'); await evalIn(tab,`document.querySelector('.pal-input').focus()`); await send('Input.insertText',{text},tab.sessionId); }; const label=text=>`document.querySelector('.palette')?.textContent.includes(${JSON.stringify(text)})`; const screenshot=async name=>{ if(!process.env.GIT_BRANCH_SCREENSHOTS)return; fs.mkdirSync(process.env.GIT_BRANCH_SCREENSHOTS,{recursive:true}); const r=await send('Page.captureScreenshot',{format:'png'},tab.sessionId); fs.writeFileSync(path.join(process.env.GIT_BRANCH_SCREENSHOTS,name+'.png'),Buffer.from(r.data,'base64')); }; await key('b'); await waitFor(tab,`document.querySelectorAll('.which-key-panel button.wk-row').length===3`,'branch menu'); assert.ok(await evalIn(tab,`[...document.querySelectorAll('.which-key-panel .wk-cmd')].every(e=>e.scrollWidth<=e.clientWidth)`),'branch menu labels are truncated'); await screenshot('menu'); await key('b'); await waitFor(tab,label('Loading branches'),'held branch listing'); const held=count('git/branches'); await key('Escape'); fs.writeFileSync(branchListReleaseFile,'release\n'); await key('b'); await key('b'); await waitFor(tab,label('Switch local branch'),'fresh branch listing'); assert.equal(count('git/branches'),held+1); await screenshot('switch'); await key('Escape'); assert.equal(count('git/switch-branch'),0,'cancelling a branch prompt mutated Git'); // The visible menu's touch/pointer route uses the same armed key map. await click('.git-status [data-slate-click="git-branch-menu"]'); await click('.which-key-panel [data-slate-key="c"]'); await waitFor(tab,label('Create and switch'),'create branch prompt'); await type('feature/browser'); await key('Enter'); await waitFor(tab,`document.body.textContent.includes('updating branch feature/browser')`,'pending branch switch'); assert.equal(branch(),'main','held switch ran early'); const mutations=count('git/create-branch'); await key('n'); await key('p'); await key('b'); await key('c'); assert.equal(count('git/create-branch'),mutations,'pending branch mutation was duplicated'); fs.writeFileSync(branchSwitchReleaseFile,'release\n'); await waitFor(tab,`document.querySelector('.git-branch-name')?.textContent==='feature/browser'`,'fresh branch status'); assert.equal(branch(),'feature/browser'); await key('b'); await key('b'); await waitFor(tab,label('Switch local branch'),'switch back prompt'); await type('main'); await key('Enter'); await waitFor(tab,`document.querySelector('.git-branch-name')?.textContent==='main'`,'switch back to main'); assert.equal(branch(),'main'); await key('l'); await waitFor(tab,`document.querySelectorAll('.git-log-row').length===50`,'history for branching'); await key('n'); await key('Enter'); await waitFor(tab,`!!document.querySelector('.git-log-inspection')`,'selected commit review'); const selected=await evalIn(tab,`document.querySelector('.git-log').dataset.reviewOid`); const head=runGit(['rev-parse','HEAD']).trim(); assert.notEqual(selected,head,'fixture did not select an older commit'); await evalIn(tab,`window.__branchReview=document.querySelector('.git-log-inspection');true`); await key('b'); await key('n'); await waitFor(tab,label('keep current checkout'),'selected commit branch prompt'); await type('review/from-history'); await key('Enter'); await waitFor(tab,`document.body.textContent.includes('Created branch review/from-history')`,'created selected-commit ref'); assert.equal(runGit(['rev-parse','refs/heads/review/from-history']).trim(),selected); assert.equal(runGit(['rev-parse','HEAD']).trim(),head); assert.equal(branch(),'main'); assert.equal(await evalIn(tab,`document.querySelector('.git-log').dataset.reviewOid`),selected,'creation lost inspected commit'); assert.ok(await evalIn(tab,`window.__branchReview===document.querySelector('.git-log-inspection')`),'ref creation replaced commit review'); await screenshot('created'); await key('b'); await key('b'); await waitFor(tab,label('Switch local branch'),'history switch prompt'); await type('feature/browser'); await key('Enter'); await waitFor(tab,`document.querySelector('.git-log-header h1')?.textContent==='feature/browser'`,'history follows switched branch'); assert.equal(await evalIn(tab,`document.querySelector('.git-log').dataset.reviewOid`),'','switch retained old checkout review'); await key('q'); await waitFor(tab,`document.querySelector('.git-branch-name')?.textContent==='feature/browser'`,'status updated while history was active'); assert.ok(sentFrames.filter(f=>/git\/(branches|switch-branch|create-branch)/.test(f)).every(f=>f.includes(gateRoot)),'branch operations lost root identity'); await send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true},tab.sessionId); await click('.git-status [data-slate-click="git-branch-menu"]'); await waitFor(tab,`!!document.querySelector('.which-key-panel button.wk-row')`,'mobile branch menu'); assert.ok(await evalIn(tab,`(()=>{const p=document.querySelector('.which-key-panel');return p.scrollWidth<=p.clientWidth})()`),'branch menu overflows mobile'); await screenshot('mobile'); await key('Escape'); console.log(JSON.stringify({branches:'PASS',root:gateRoot,pointer:true,cancel:true,heldMutation:true,selectedCommit:true,preservedReview:true}));}packages/slate/test/git-branch-command-test.sgladded
(import (sigil test) (slate command) (slate commands git) (slate commands git branch) (slate commands prompt) (slate git model) (slate git log-model) (slate git log-view) (slate modes git-status) (slate modes git-log) (slate pane) (slate prompt) (slate provider) (slate root) (slate space) (slate state) (slate view))(define STATE #{})(define REQUESTS '())(define HELD '())(define OID (make-string 40 #\a))(define OLD (make-string 40 #\b))(define REPO #{ root: "branches" worktree: "/repo" common-dir: "/repo/.git" })(define SNAP #{ head-oid: OID index-id: "i" worktree-id: "w" })(define DATA #{ version: 1 repo: REPO snapshot: SNAP branch: #{ head: "main" } entries: '() recent-commits: '() })(define LISTING #{ version: 1 repo: REPO snapshot: SNAP branch: #{ head: "main" } branches: (list #{ name: "main" oid: OID } #{ name: "old" oid: OLD }) })(register-provider! branch-test: #{ git-request: (lambda (source verb args ready fail) (set! REQUESTS (append REQUESTS (list (dict-set args verb: verb)))) (cond ((string=? verb "git/discover") (ready #{ version: 1 node-repo: REPO })) ((string=? verb "git/status") (ready DATA)) ((dict-ref args cancel: #f) #t) (else (set! HELD (append HELD (list (cons ready fail))))))) subscribe: (lambda (source callback) (lambda () #t)) })(register-root! branches: #{ provider: branch-test: host: "remote-branch-node" class: machine: })(define (run command) (set! STATE (command-run STATE command)))(define (setup!) (set! REQUESTS '()) (set! HELD '()) (let ((v (view slate/name: "plain" slate/source: (root->source branches: file: "/repo/a.sgl")))) (set! STATE #{ slate/views: (list v) slate/spaces: (list (make-space "main" (make-leaf 0 "plain") 0 1)) slate/active-space: "main" })) (configure-git-commands! #{ state: (lambda () STATE) set-state: (lambda (state) (set! STATE state)) refresh: (lambda () #t) }) (configure-prompt-commands! #{ desc: (lambda (state id) (git-branch-prompt state)) visible: (lambda (state) (prompt-candidates (git-branch-prompt state) state)) }) (run "git-status"))(define (ready value) ((car (car (reverse HELD))) value))(define (accept value) (if (string? value) (set! STATE (dict-set STATE slate/selector-query: value)) (let loop ((rows (prompt-candidates (git-branch-prompt STATE) STATE)) (i 0)) (if (equal? (car rows) value) (set! STATE (dict-set STATE slate/selector-index: i)) (loop (cdr rows) (+ i 1))))) (set! STATE (accept-prompt STATE)))(test "branch menu arms keyboard and pointer commands without fetching" (setup!) (let ((before (length REQUESTS))) (run "git-branch-menu") (assert-equal "git-branch-switch" (dict-ref (dict-ref STATE slate/pending-prefix: #{}) "b" #f)) (assert-equal before (length REQUESTS))))(test "late and wrong-root branch listings cannot reopen a cancelled prompt" (setup!) (run "git-branch-switch") (assert-equal "Loading branches…" (prompt-label (git-branch-prompt STATE))) (set! STATE (close-prompt (cancel-prompt STATE))) (ready LISTING) (assert-false (dict-ref STATE slate/minibuffer: #f)) (run "git-branch-switch") (ready (dict-set LISTING repo: (dict-set REPO root: "wrong"))) (assert-false (dict-ref STATE slate/minibuffer: #f)))(test "switch carries the captured snapshot, root, and target and excludes other mutations" (setup!) (run "git-branch-switch") (assert-equal live: (prompt-candidate-policy (git-branch-prompt STATE))) (ready LISTING) (assert-equal snapshot: (prompt-candidate-policy (git-branch-prompt STATE))) (let* ((m (git-status-model (active-view STATE))) (candidate (cadr (prompt-candidates (git-branch-prompt STATE) STATE)))) (accept candidate) (let ((sent (car (reverse REQUESTS))) (before (length REQUESTS))) (assert-equal "git/switch-branch" (dict-ref sent verb: #f)) (assert-equal REPO (dict-ref sent repo: #f)) (assert-equal SNAP (dict-ref sent expected-snapshot: #f)) (assert-equal OLD (dict-ref sent target-oid: #f)) (assert-equal pending: (dict-ref (git-status-model-state m) mutation: #f)) (run "git-branch-create-switch") (run "git-refresh") (assert-equal before (length REQUESTS))) (ready #{ version: 1 repo: REPO name: "old" applied: #t switched: #t status: (dict-set DATA branch: #{ head: "old" }) }) (assert-equal "old" (dict-ref (dict-ref (dict-ref (git-status-model-state m) data: #{}) branch: #{}) head: #f)) (assert-false (dict-ref STATE slate/minibuffer: #f))))(test "create prompts validate duplicates and carry HEAD without switching early" (setup!) (run "git-branch-create-switch") (ready LISTING) (assert-true (string? (prompt-validate (git-branch-prompt STATE) STATE "main"))) (assert-equal 3 (length REQUESTS)) (accept "feature/new") (let ((sent (car (reverse REQUESTS)))) (assert-equal "git/create-branch" (dict-ref sent verb: #f)) (assert-equal OID (dict-ref sent start-oid: #f)) (assert-true (dict-ref sent switch: #f))) ((cdr (car (reverse HELD))) #{ message: "Branch exists" }) (assert-equal "Branch exists" (dict-ref STATE slate/echo: #f)) (assert-false (eq? pending: (dict-ref (git-status-model-state (git-status-model (active-view STATE))) mutation: #f))))(test "selected history commit is captured before prompting and creates without switching" (setup!) (run "git-log") (ready #{ version: 1 repo: REPO snapshot: SNAP branch: #{ head: "main" } commits: (list #{ oid: OLD subject: "Older" parents: '() }) offset: 0 complete: #t }) (run "git-branch-create-selected") (ready LISTING) (accept "review/old") (let ((sent (car (reverse REQUESTS)))) (assert-equal OLD (dict-ref sent start-oid: #f)) (assert-false (dict-ref sent switch: #t))) (ready #{ version: 1 repo: REPO name: "review/old" applied: #t switched: #f status: DATA }) (assert-true (vector? (git-log-model (active-view STATE)))) (assert-equal "git/log" (dict-ref (car (reverse REQUESTS)) verb: #f)))(test "invalid mutation receipts release the pending state without adopting another root" (setup!) (run "git-branch-create-switch") (ready LISTING) (accept "new") (ready #{ version: 1 repo: (dict-set REPO root: "other") name: "new" applied: #t status: DATA }) (assert-equal stale: (dict-ref (git-status-model-state (git-status-model (active-view STATE))) state: #f)) (run "git-branch-switch") (assert-equal "git/branches" (dict-ref (car (reverse REQUESTS)) verb: #f)))(configure-git-commands! #{})(test-summary)packages/slate/test/git-contract-gate.mjsadded
import assert from 'node:assert/strict';import { execFileSync, spawnSync } from 'node:child_process';import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';import { join, resolve } from 'node:path';const [contractPath, fixtureRoot] = process.argv.slice(2);if (!contractPath || !fixtureRoot) { console.error('SETUP-FAILED git-contract: expected declaration and fixture root'); process.exit(2);}const fail = message => { throw new Error(message); };let contract;try { contract = JSON.parse(readFileSync(contractPath, 'utf8'));} catch (error) { console.error(`SETUP-FAILED git-contract: cannot read declaration: ${error.message}`); process.exit(2);}const gitEnv = { ...process.env, LC_ALL: 'C', LANG: 'C', GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 'Slate Contract', GIT_AUTHOR_EMAIL: 'slate-contract@example.invalid', GIT_COMMITTER_NAME: 'Slate Contract', GIT_COMMITTER_EMAIL: 'slate-contract@example.invalid', GIT_TERMINAL_PROMPT: '0',};function git(cwd, ...args) { return execFileSync('git', args, { cwd, env: gitEnv, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });}function initRepo(name, initial = true) { const dir = join(fixtureRoot, name); mkdirSync(dir, { recursive: true }); git(dir, 'init', '-q', '--initial-branch=main'); if (initial) { writeFileSync(join(dir, 'base.txt'), 'one\ntwo\nthree\nfour\nfive\n'); git(dir, 'add', '--', 'base.txt'); git(dir, 'commit', '-q', '-m', 'initial'); } return dir;}function buildFixtures() { const fixtures = new Map(); fixtures.set('clean', initRepo('clean')); fixtures.set('unborn', initRepo('unborn', false)); const mixed = initRepo('mixed'); writeFileSync(join(mixed, 'base.txt'), 'ONE\ntwo\nthree\nfour\nfive\n'); git(mixed, 'add', '--', 'base.txt'); writeFileSync(join(mixed, 'base.txt'), 'ONE\ntwo\nthree\nfour\nFIVE\n'); writeFileSync(join(mixed, 'untracked.txt'), 'new\n'); fixtures.set('mixed', mixed); const rename = initRepo('rename'); writeFileSync(join(rename, 'old name.txt'), 'rename me\n'); git(rename, 'add', '--', 'old name.txt'); git(rename, 'commit', '-q', '-m', 'rename base'); git(rename, 'mv', '--', 'old name.txt', 'new -> name.txt'); fixtures.set('rename', rename); const binary = initRepo('binary'); writeFileSync(join(binary, 'blob.bin'), Buffer.from([0, 1, 2, 0, 255])); git(binary, 'add', '--', 'blob.bin'); fixtures.set('binary', binary); const mode = initRepo('mode'); writeFileSync(join(mode, 'run.sh'), '#!/bin/sh\nexit 0\n'); git(mode, 'add', '--', 'run.sh'); git(mode, 'commit', '-q', '-m', 'mode base'); chmodSync(join(mode, 'run.sh'), 0o755); fixtures.set('mode-change', mode); const special = initRepo('special-names'); const names = ['space name.txt', 'tab\tname.txt', 'line\nname.txt', 'naïve.txt', '-leading.txt', 'from -> to.txt']; for (const name of names) writeFileSync(join(special, name), `${name}\n`); git(special, 'add', '--', ...names); fixtures.set('special-names', special); const detached = initRepo('detached'); git(detached, 'checkout', '-q', '--detach', 'HEAD'); fixtures.set('detached', detached); const divergence = initRepo('ahead-behind'); git(divergence, 'branch', 'upstream-base'); git(divergence, 'branch', '--set-upstream-to=upstream-base', 'main'); writeFileSync(join(divergence, 'ahead.txt'), 'ahead\n'); git(divergence, 'add', '--', 'ahead.txt'); git(divergence, 'commit', '-q', '-m', 'ahead'); git(divergence, 'checkout', '-q', 'upstream-base'); writeFileSync(join(divergence, 'behind.txt'), 'behind\n'); git(divergence, 'add', '--', 'behind.txt'); git(divergence, 'commit', '-q', '-m', 'behind'); git(divergence, 'checkout', '-q', 'main'); fixtures.set('ahead-behind', divergence); const conflict = initRepo('conflict'); git(conflict, 'checkout', '-q', '-b', 'other'); writeFileSync(join(conflict, 'base.txt'), 'other\n'); git(conflict, 'commit', '-qam', 'other'); git(conflict, 'checkout', '-q', 'main'); writeFileSync(join(conflict, 'base.txt'), 'main\n'); git(conflict, 'commit', '-qam', 'main'); try { git(conflict, 'merge', 'other'); } catch { /* expected conflict keeps MERGE_HEAD */ } fixtures.set('conflict', conflict); fixtures.set('in-progress', conflict); const multi = initRepo('multi-hunk'); const lines = Array.from({ length: 80 }, (_, i) => `line ${i + 1}`); writeFileSync(join(multi, 'many.txt'), `${lines.join('\n')}\n`); git(multi, 'add', '--', 'many.txt'); git(multi, 'commit', '-q', '-m', 'many base'); lines[2] = 'changed first hunk'; lines[70] = 'changed second hunk'; writeFileSync(join(multi, 'many.txt'), `${lines.join('\n')}\n`); fixtures.set('multi-hunk', multi); const large = initRepo('large-diff'); const huge = Array.from({ length: 90000 }, (_, i) => `old ${String(i).padStart(6, '0')} ${'x'.repeat(32)}`); writeFileSync(join(large, 'huge.txt'), `${huge.join('\n')}\n`); git(large, 'add', '--', 'huge.txt'); git(large, 'commit', '-q', '-m', 'large base'); const changed = huge.map((line, i) => i % 2 === 0 ? line.replace('old', 'new') : line); writeFileSync(join(large, 'huge.txt'), `${changed.join('\n')}\n`); fixtures.set('large-diff', large); return fixtures;}const typeChecks = { string: value => typeof value === 'string', number: value => typeof value === 'number' && Number.isFinite(value), boolean: value => typeof value === 'boolean', object: value => value !== null && typeof value === 'object' && !Array.isArray(value), array: Array.isArray, 'nullable-object': value => value === null || typeChecks.object(value), 'nullable-string': value => value === null || typeof value === 'string', repo: value => typeChecks.object(value) && ['root', 'worktree', 'common_dir'].every(k => typeof value[k] === 'string'), 'node-repo': value => typeChecks.object(value) && ['worktree', 'common_dir'].every(k => typeof value[k] === 'string') && !Object.hasOwn(value, 'root'), snapshot: value => typeChecks.object(value) && ['head_oid', 'index_id', 'worktree_id'].every(k => typeof value[k] === 'string'), hunk: value => typeChecks.object(value) && typeof value.file_id === 'string' && ['old_start', 'old_lines', 'new_start', 'new_lines'].every(k => Number.isInteger(value[k])) && typeof value.side === 'string' && typeof value.context_hash === 'string', 'diff-kind': value => ['worktree-file', 'index-file', 'staged-all', 'commit'].includes(value),};function validateSchema(schema, value, label) { assert(typeChecks.object(value), `${label}: value is not an object`); assert(typeChecks.object(schema) && typeChecks.object(schema.required), `${label}: required schema missing`); for (const [field, type] of Object.entries(schema.required)) { assert(typeChecks[type], `${label}: unknown field type ${type}`); assert(Object.hasOwn(value, field), `${label}: missing ${field}`); assert(typeChecks[type](value[field]), `${label}: ${field} is not ${type}`); } return value;}const repoValue = { root: 'home', worktree: '/work/app', common_dir: '/work/app/.git' };const snapshotValue = { head_oid: 'a'.repeat(40), index_id: 'index-1', worktree_id: 'worktree-1' };const hunkValue = { file_id: 'file-1', side: 'worktree', old_start: 1, old_lines: 2, new_start: 1, new_lines: 2, context_hash: 'ctx-1' };function requestExample(verb) { if (verb === 'git/discover') return { path: '/work/app' }; if (verb === 'git/status') return { repo: repoValue, recent_limit: 20 }; if (verb === 'git/diff') return { repo: repoValue, snapshot: snapshotValue, kind: 'worktree-file', target: 'file-1', cursor: null, max_lines: 400, max_bytes: 262144 }; if (verb.endsWith('-file')) return { repo: repoValue, expected_snapshot: snapshotValue, entry_id: 'file-1' }; return { repo: repoValue, expected_snapshot: snapshotValue, hunk_id: hunkValue };}function resultExample(verb) { const base = { version: 1, repo: repoValue }; if (verb === 'git/discover') return { version: 1, node_repo: { worktree: '/work/app', common_dir: '/work/app/.git' }, bare: false, object_format: 'sha1' }; if (verb === 'git/status') return { ...base, snapshot: snapshotValue, branch: {}, operation: null, entries: [], recent_commits: [], capabilities: {} }; if (verb === 'git/diff') return { ...base, snapshot: snapshotValue, files: [], next_cursor: null, complete: true, truncation: null }; return { ...base, snapshot: snapshotValue };}class FakeNodeLane { constructor() { this.records = []; this.held = []; this.active = 0; this.peak = 0; } request(destination, verb, args, onReady) { const record = { id: this.records.length + 1, destination, verb, args, bytes: Buffer.byteLength(JSON.stringify(args)), outcome: 'held' }; this.records.push(record); this.held.push(() => { this.active += 1; this.peak = Math.max(this.peak, this.active); const result = resultExample(verb); record.reply_bytes = Buffer.byteLength(JSON.stringify(result)); record.outcome = 'ok'; onReady(result, record); this.active -= 1; }); return record.id; } release(index = 0) { const deliver = this.held.splice(index, 1)[0]; assert(deliver, 'no held reply'); deliver(); }}try { assert.equal(contract.version, 1, 'contract version must be 1'); assert.deepEqual(contract.errors, ['not-a-repository', 'git-unavailable', 'grant-denied', 'stale-snapshot', 'conflict', 'operation-failed'], 'error vocabulary drifted'); assert.deepEqual(contract.limits, { diff_page_lines: 400, diff_page_bytes: 262144, diff_concurrency: 2, mounted_rows: 800, recent_commits: 50, error_bytes: 4096 }, 'v1 ceilings drifted'); const fixtures = buildFixtures(); for (const required of ['clean', 'unborn', 'mixed', 'rename', 'binary', 'mode-change', 'conflict', 'detached', 'ahead-behind', 'in-progress', 'special-names', 'multi-hunk', 'large-diff']) { assert(fixtures.has(required), `fixture missing: ${required}`); } assert(git(fixtures.get('mixed'), 'status', '--porcelain=v2', '-z').includes('base.txt'), 'mixed fixture has no status records'); assert(git(fixtures.get('conflict'), 'status', '--porcelain=v2', '-z').startsWith('u '), 'conflict fixture has no unmerged record'); const detachedProbe = spawnSync('git', ['symbolic-ref', '-q', 'HEAD'], { cwd: fixtures.get('detached'), env: gitEnv, encoding: 'utf8' }); assert.equal(detachedProbe.status, 1, 'detached fixture is attached'); assert(git(fixtures.get('ahead-behind'), 'rev-list', '--count', 'upstream-base..main').trim() === '1', 'ahead fixture is not ahead'); assert(git(fixtures.get('ahead-behind'), 'rev-list', '--count', 'main..upstream-base').trim() === '1', 'ahead fixture is not behind'); assert(readFileSync(join(fixtures.get('large-diff'), 'huge.txt')).byteLength > 3 * 1024 * 1024, 'large diff fixture is not multi-megabyte'); const specialStatus = git(fixtures.get('special-names'), 'status', '--porcelain=v2', '-z'); for (const name of ['space name.txt', 'tab\tname.txt', 'line\nname.txt', 'naïve.txt', '-leading.txt', 'from -> to.txt']) assert(specialStatus.includes(name), `special filename missing: ${JSON.stringify(name)}`); const verbs = Object.entries(contract.verbs || {}); assert(verbs.length > 0, 'contract declares no verbs'); for (const [verb, spec] of verbs) { assert(typeChecks.object(spec.request), `${verb}: missing request validation`); assert(spec.fixture && fixtures.has(spec.fixture), `${verb}: missing fixture`); if (!typeChecks.object(spec.decoder)) fail(`${verb}: missing decoder`); assert(Array.isArray(spec.errors) && spec.errors.length > 0, `${verb}: missing error arm`); for (const code of spec.errors) assert(contract.errors.includes(code), `${verb}: unknown error arm ${code}`); validateSchema(spec.request, requestExample(verb), `${verb} request`); validateSchema(spec.decoder, resultExample(verb), `${verb} decoder`); const missing = { ...requestExample(verb) }; delete missing[Object.keys(spec.request.required)[0]]; assert.throws(() => validateSchema(spec.request, missing, `${verb} invalid request`), /missing/, `${verb}: validator accepted missing field`); } const lane = new FakeNodeLane(); const results = []; lane.request('node-local', 'git/status', requestExample('git/status'), result => results.push(result)); lane.request('node-remote', 'git/status', requestExample('git/status'), result => results.push(result)); assert.equal(results.length, 0, 'held reply delivered synchronously'); lane.release(1); // Deliberately deliver remote before local. lane.release(0); assert.deepEqual(results[0], results[1], 'local/remote semantic results differ'); assert.deepEqual(lane.records.map(r => r.destination), ['node-local', 'node-remote'], 'root destinations were not distinct'); assert(lane.records.every(r => r.id && r.bytes > 0 && r.reply_bytes > 0 && r.outcome === 'ok'), 'correlation/accounting record incomplete'); console.log(`PASS git-contract: ${verbs.length}/${verbs.length} verbs; local/remote root matrix 2/2`);} catch (error) { console.error(`git-contract: FAIL -- ${error.message}`); process.exit(1);}packages/slate/test/git-contract-v1.jsonadded
{ "version": 1, "limits": { "diff_page_lines": 400, "diff_page_bytes": 262144, "diff_concurrency": 2, "mounted_rows": 800, "recent_commits": 50, "error_bytes": 4096 }, "errors": [ "not-a-repository", "git-unavailable", "grant-denied", "stale-snapshot", "conflict", "operation-failed" ], "verbs": { "git/discover": { "request": {"required": {"path": "string"}}, "fixture": "clean", "decoder": {"required": {"version": "number", "node_repo": "node-repo", "bare": "boolean", "object_format": "string"}}, "errors": ["not-a-repository", "git-unavailable", "grant-denied", "operation-failed"] }, "git/status": { "request": {"required": {"repo": "repo", "recent_limit": "number"}}, "fixture": "mixed", "decoder": {"required": {"version": "number", "repo": "repo", "snapshot": "snapshot", "branch": "object", "operation": "nullable-object", "entries": "array", "recent_commits": "array", "capabilities": "object"}}, "errors": ["not-a-repository", "git-unavailable", "grant-denied", "conflict", "operation-failed"] }, "git/diff": { "request": {"required": {"repo": "repo", "snapshot": "snapshot", "kind": "diff-kind", "target": "nullable-string", "cursor": "nullable-string", "max_lines": "number", "max_bytes": "number"}}, "fixture": "large-diff", "decoder": {"required": {"version": "number", "repo": "repo", "snapshot": "snapshot", "files": "array", "next_cursor": "nullable-string", "complete": "boolean", "truncation": "nullable-object"}}, "errors": ["not-a-repository", "git-unavailable", "grant-denied", "stale-snapshot", "conflict", "operation-failed"] }, "git/stage-file": { "request": {"required": {"repo": "repo", "expected_snapshot": "snapshot", "entry_id": "string"}}, "fixture": "mixed", "decoder": {"required": {"version": "number", "repo": "repo", "snapshot": "snapshot"}}, "errors": ["not-a-repository", "git-unavailable", "grant-denied", "stale-snapshot", "conflict", "operation-failed"] }, "git/unstage-file": { "request": {"required": {"repo": "repo", "expected_snapshot": "snapshot", "entry_id": "string"}}, "fixture": "mixed", "decoder": {"required": {"version": "number", "repo": "repo", "snapshot": "snapshot"}}, "errors": ["not-a-repository", "git-unavailable", "grant-denied", "stale-snapshot", "conflict", "operation-failed"] }, "git/stage-hunk": { "request": {"required": {"repo": "repo", "expected_snapshot": "snapshot", "hunk_id": "hunk"}}, "fixture": "multi-hunk", "decoder": {"required": {"version": "number", "repo": "repo", "snapshot": "snapshot"}}, "errors": ["not-a-repository", "git-unavailable", "grant-denied", "stale-snapshot", "conflict", "operation-failed"] }, "git/unstage-hunk": { "request": {"required": {"repo": "repo", "expected_snapshot": "snapshot", "hunk_id": "hunk"}}, "fixture": "multi-hunk", "decoder": {"required": {"version": "number", "repo": "repo", "snapshot": "snapshot"}}, "errors": ["not-a-repository", "git-unavailable", "grant-denied", "stale-snapshot", "conflict", "operation-failed"] } }}packages/slate/test/git-diff-browser-gate.mjsadded
// A4 shipped-artifact drive builds on the A3 user-door fixture and enables// held, paged diff review assertions.process.env.A4_DIFF_GATE = '1';await import('./git-status-browser-gate.mjs');packages/slate/test/git-diff-view-test.sgladded
;;; A4 provider-neutral diff model, bounded view and late-page rejection.(import (sigil core) (sigil test) (slate provider) (slate root) (slate git diff-model) (slate git diff-view) (slate mode) (slate modes git-diff) (slate view))(define HELD '())(define REQUESTS '())(define (held-git source verb args on-ready on-error) (set! REQUESTS (append REQUESTS (list args))) (set! HELD (append HELD (list (lambda () (on-ready #{ version: 1 repo: (dict-ref args repo: #{}) snapshot: (dict-ref args snapshot: #{}) files: (list #{ id: "f" kind: "worktree" target: "a.sgl" hunks: (list #{ kind: hunk: text: "@@ -1 +1 @@" old-line: #f new-line: #f } #{ kind: deletion: text: "-old" old-line: 1 new-line: #f } #{ kind: addition: text: "+new" old-line: #f new-line: 1 }) }) payload-bytes: 1024 next-cursor: (if (dict-ref args cursor: #f) #f "diff:3") complete: (if (dict-ref args cursor: #f) #t #f) truncation: #f }))))))(register-provider! diff-held: #{ git-request: held-git })(register-root! diff-held: #{ provider: diff-held: host: "remote" class: machine: })(define SOURCE (root->source diff-held: file: "/repo/a.sgl"))(define REPO #{ root: diff-held: worktree: "/repo" common-dir: "/repo/.git" })(define SNAPSHOT #{ head-oid: "h" index-id: "i" worktree-id: "w" })(define (model) (make-git-diff-model SOURCE REPO SNAPSHOT "worktree" "a.sgl" #f))(test "diff shell loads before a held remote page" (set! HELD '()) (set! REQUESTS '()) (let ((m (model))) (git-diff-model-load! m) (assert-equal loading: (dict-ref (git-diff-model-state m) state: #f)) (assert-equal 1 (length REQUESTS)) ((car HELD)) (assert-equal ready: (dict-ref (git-diff-model-state m) state: #f))))(test "late page after invalidation cannot mutate current view" (set! HELD '()) (set! REQUESTS '()) (let ((m (model))) (git-diff-model-load! m) (git-diff-model-invalidate! m) ((car HELD)) (assert-equal #t (dict-ref (cadr REQUESTS) cancel: #f)) (assert-equal "diff:3" (dict-ref (cadr REQUESTS) cursor: #f)) (assert-equal idle: (dict-ref (git-diff-model-state m) state: #f)) (assert-equal '() (dict-ref (git-diff-model-state m) pages: '(bad)))))(test "continuation is explicit and mounted rows stay bounded" (set! HELD '()) (let ((m (model))) (git-diff-model-load! m) ((car HELD)) (let ((v #{ slate/git-diff-model: m slate/git-selected: #f })) (assert-equal 4 (length (git-diff-rows v))) (assert-equal continuation: (dict-ref (list-ref (git-diff-rows v) 3) kind: #f)) (assert-true (<= (length (git-diff-rows v)) 800)))))(test "load more is cursor based and appends a bounded page" (set! HELD '()) (set! REQUESTS '()) (let ((m (model))) (git-diff-model-load! m) ((car HELD)) (git-diff-model-more! m) (assert-equal "diff:3" (dict-ref (cadr REQUESTS) cursor: #f)) ((cadr HELD)) (assert-equal #t (dict-ref (git-diff-model-state m) complete: #f))))(test "client state retains only the two-page render window" (let* ((base #{ state: idle: pages: '() }) (one (git-diff-reduce base #{ event: page: generation: 1 page: #{ files: '() next-cursor: "1" complete: #f } })) (two (git-diff-reduce one #{ event: page: generation: 2 page: #{ files: '() next-cursor: "2" complete: #f } })) (three (git-diff-reduce two #{ event: page: generation: 3 page: #{ files: '() next-cursor: "3" complete: #f } }))) (assert-equal 2 (length (dict-ref three pages: '())))))(test "diff mode and root-qualified name are registered" (assert-true (dict? (mode-descriptor 'git-diff-mode))) (assert-true (string? (git-diff-view-name REPO "worktree" "a.sgl"))))(test "deferred review retains presentation but revokes its cursor without fetching" (set! HELD '()) (set! REQUESTS '()) (let ((m (model))) (git-diff-model-load! m) ((car HELD)) (let ((lines (git-diff-lines (git-diff-model-state m)))) (git-diff-model-defer! m REPO #{ head-oid: "h" index-id: "fresh" worktree-id: "w" }) (assert-equal 2 (length REQUESTS)) (assert-true (dict-ref (cadr REQUESTS) cancel: #f)) (assert-equal SNAPSHOT (dict-ref (cadr REQUESTS) snapshot: #f)) (assert-equal lines (git-diff-lines (git-diff-model-state m))) (assert-false (dict-ref (git-diff-model-state m) next-cursor: #f)) (git-diff-model-more! m) (assert-equal 2 (length REQUESTS)) (git-diff-model-resume! m) (git-diff-model-resume! m) (assert-equal 3 (length REQUESTS)) (assert-equal lines (git-diff-lines (git-diff-model-state m))) ((list-ref HELD 2)) (assert-equal 1 (length (dict-ref (git-diff-model-state m) pages: '()))) (assert-false (dict-ref (git-diff-model-state m) rebasing: #f)))))(test "late page cancellation keeps the original snapshot after rebase" (set! HELD '()) (set! REQUESTS '()) (let ((m (model))) (git-diff-model-load! m) (git-diff-model-reload! m REPO #{ head-oid: "h" index-id: "fresh" worktree-id: "w" }) ((car HELD)) (assert-equal SNAPSHOT (dict-ref (list-ref REQUESTS 2) snapshot: #f)) (assert-true (dict-ref (list-ref REQUESTS 2) cancel: #f)) (assert-equal '() (git-diff-lines (git-diff-model-state m))) ((cadr HELD)) (assert-equal ready: (dict-ref (git-diff-model-state m) state: #f))))packages/slate/test/git-discard-refresh-browser-gate.mjsadded
import { spawnSync } from 'node:child_process';const gate = new URL('./git-status-browser-gate.mjs', import.meta.url);const result = spawnSync(process.execPath, [gate.pathname], { cwd: process.cwd(), env: { ...process.env, B3_DISCARD_REFRESH_GATE: '1' }, encoding: 'utf8'});process.stdout.write(result.stdout || '');process.stderr.write(result.stderr || '');process.exit(result.status ?? 1);packages/slate/test/git-hunk-browser-gate.mjsadded
import { spawnSync } from 'node:child_process';const gate = new URL('./git-status-browser-gate.mjs', import.meta.url);const result = spawnSync(process.execPath, [gate.pathname], { cwd: process.cwd(), env: { ...process.env, B2_HUNK_GATE: '1' }, encoding: 'utf8'});process.stdout.write(result.stdout || '');process.stderr.write(result.stderr || '');process.exit(result.status ?? 1);packages/slate/test/git-log-browser-gate.mjsadded
import assert from 'node:assert/strict';import fs from 'node:fs';import path from 'node:path';export async function historyGate({tab, send, sgl, evalIn, waitFor, key, sentFrames, diffReleaseFile, historyReleaseFile, gateRoot}) { const dispatch = command => sgl(`(slate-dispatch ${JSON.stringify(command)})`); const count = verb => sentFrames.filter(frame => frame.includes(verb)).length; const screenshot = async name => { if (!process.env.GIT_HISTORY_SCREENSHOTS) return; fs.mkdirSync(process.env.GIT_HISTORY_SCREENSHOTS, {recursive:true}); const result = await send('Page.captureScreenshot', {format:'png'}, tab.sessionId); fs.writeFileSync(path.join(process.env.GIT_HISTORY_SCREENSHOTS, name+'.png'), Buffer.from(result.data,'base64')); }; await send('Emulation.setDeviceMetricsOverride', {width:1280,height:900,deviceScaleFactor:1,mobile:false},tab.sessionId); await dispatch('git-layout:auto'); const before = count('git/log'); await key('l'); await waitFor(tab, `document.querySelector('.git-log')?.textContent.includes('Loading history')`, 'responsive history shell'); await key('g'); assert.equal(count('git/log'),before+1,'refresh duplicated held history request'); await key('q'); await waitFor(tab, `!!document.querySelector('.git-status')`, 'close pending history'); fs.writeFileSync(historyReleaseFile,'release\n'); await key('l'); await waitFor(tab, `document.querySelectorAll('.git-log-row').length === 50`, 'first bounded history page'); assert.ok(sentFrames.filter(f=>f.includes('git/log')).every(f=>f.includes(gateRoot)), 'history request lost root identity'); assert.equal(count('git/show'),0,'history list eagerly inspected a commit'); const first = await evalIn(tab, `document.querySelector('.git-log-row').dataset.oid`); const requests = sentFrames.length; await key('n'); await key('p'); assert.equal(await evalIn(tab, `document.querySelector('.git-log-row.selected').dataset.oid`),first); assert.equal(sentFrames.length,requests,'history navigation started network work'); fs.writeFileSync(diffReleaseFile,'release\n'); // A real pointer click both selects and loads, without an extra Enter. const point = await evalIn(tab, `(()=>{const r=document.querySelector('.git-log-row').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`); await send('Input.dispatchMouseEvent',{type:'mousePressed',button:'left',clickCount:1,...point},tab.sessionId); await send('Input.dispatchMouseEvent',{type:'mouseReleased',button:'left',clickCount:1,...point},tab.sessionId); await waitFor(tab, `[...document.querySelectorAll('.git-log-review .slate-diff-addition')].some(row=>row.textContent.includes('history inspection marker'))`, 'selected commit patch'); assert.ok(sentFrames.filter(f=>/git\/(show|diff)/.test(f)).every(f=>f.includes(gateRoot)), 'commit inspection routing identity mismatch'); assert.equal(await evalIn(tab, `getComputedStyle(document.querySelector('.git-log-workbench')).display`),'grid'); await evalIn(tab, `window.__historyReview=document.querySelector('.git-log-inspection'); true`); const loadedRequests = sentFrames.length; await key('n'); await key('p'); assert.ok(await evalIn(tab, `window.__historyReview===document.querySelector('.git-log-inspection')`), 'history selection replaced the memoized review'); assert.equal(sentFrames.length,loadedRequests,'moving with an open review fetched data'); assert.ok(await evalIn(tab, `document.querySelector('.git-log-message strong')?.textContent==='committed change' && !!document.querySelector('.git-log-message li code') && !!document.querySelector('.git-log-message pre code')`), 'commit Markdown was not rendered'); assert.equal(await evalIn(tab, `getComputedStyle(document.querySelector('.git-log-review .slate-diff-control')).borderLeftWidth`),'0px'); await key('Enter'); assert.equal(await evalIn(tab, `document.querySelector('.git-log').dataset.focus`),'review'); await key('PageDown'); await waitFor(tab, `document.querySelector('.git-log-review-body').scrollTop>100`, 'keyboard review page scroll'); const pageScroll = await evalIn(tab, `document.querySelector('.git-log-review-body').scrollTop`); await key('n'); await waitFor(tab, `document.querySelector('.git-log-review-body').scrollTop>${pageScroll}`, 'review n scroll'); assert.equal(await evalIn(tab, `document.querySelector('.git-log-row.selected').dataset.oid`),first,'review scroll moved commit selection'); assert.ok(await evalIn(tab, `window.__historyReview===document.querySelector('.git-log-inspection')`), 'review scroll rebuilt patch DOM'); assert.equal(sentFrames.length,loadedRequests,'focus or scroll fetched data'); await key('Home'); await waitFor(tab, `document.querySelector('.git-log-review-body').scrollTop===0`, 'review Home'); await key('Tab'); assert.equal(await evalIn(tab, `document.querySelector('.git-log').dataset.focus`),'list'); await key('Tab'); assert.equal(await evalIn(tab, `document.querySelector('.git-log').dataset.focus`),'review'); await key('Escape'); assert.equal(await evalIn(tab, `document.querySelector('.git-log').dataset.focus`),'list'); await screenshot('split'); // Real keyboard clipboard uses the existing single-shot command clipboard seam. await evalIn(tab, `window.__historyClipboard=''; Object.defineProperty(navigator,'clipboard', {configurable:true,value:{writeText:async text=>{window.__historyClipboard=text;}}}); true`); await key('w'); assert.equal(await evalIn(tab, `window.__historyClipboard`),first,'keyboard Copy ID failed'); await key(']'); await waitFor(tab, `document.querySelector('.git-log-pagination')?.textContent.includes('51–')`, 'older history page'); assert.ok(await evalIn(tab, `document.querySelectorAll('.git-log-row').length <= 50`),'history DOM grew beyond its page'); await key('['); await waitFor(tab, `document.querySelector('.git-log-row')?.dataset.oid===${JSON.stringify(first)}`, 'newer history page'); await dispatch('git-layout:inline'); assert.ok(await evalIn(tab, `(()=>{const l=document.querySelector('.git-log-list').getBoundingClientRect(),r=document.querySelector('.git-log-review').getBoundingClientRect(),w=document.querySelector('.git-log-workbench').getBoundingClientRect();return r.top>=l.bottom-1 && r.bottom<=w.bottom+1 && r.height>150})()`),'stacked review is not visible alongside list'); await key('Enter'); await waitFor(tab, `!!document.querySelector('.git-log-inspection')`, 'reopen review after paging'); await screenshot('stacked'); await dispatch('git-layout:auto'); await send('Emulation.setDeviceMetricsOverride', {width:390,height:844,deviceScaleFactor:1,mobile:true},tab.sessionId); await waitFor(tab, `(()=>{const l=document.querySelector('.git-log-list').getBoundingClientRect(),r=document.querySelector('.git-log-review').getBoundingClientRect(),v=document.querySelector('.git-log').getBoundingClientRect();return r.top>=l.bottom-1 && r.bottom<=v.bottom && r.height>100})()`, 'mobile inline history'); assert.ok(await evalIn(tab, `(()=>{const v=document.querySelector('.git-log');return v.scrollWidth<=v.clientWidth})()`), 'history overflows mobile width'); assert.ok(await evalIn(tab, `document.querySelector('.git-log-row').getBoundingClientRect().height>=44`), 'history touch targets too small'); await key('PageDown'); await waitFor(tab, `document.querySelector('.git-log-review-body').scrollTop>50`, 'mobile keyboard review scroll'); await key('Home'); await screenshot('mobile'); await send('Emulation.setDeviceMetricsOverride', {width:1280,height:900,deviceScaleFactor:1,mobile:false},tab.sessionId); await key('q'); await waitFor(tab, `!!document.querySelector('.git-status')`, 'return to status'); console.log(JSON.stringify({history:'PASS',root:gateRoot,boundedPages:true,lazyReview:true,clipboard:true}));}packages/slate/test/git-log-test.sgladded
(import (sigil test) (slate provider) (slate root) (slate git log-model) (slate git log-view))(define OID-A (make-string 40 #\a))(define OID-B (make-string 40 #\b))(define OID-C (make-string 40 #\c))(define REPO #{ root: "history" worktree: "/same/repo" common-dir: "/same/repo/.git" })(define SNAP #{ head-oid: OID-A index-id: "i" worktree-id: "w" })(define REQUESTS '())(define HELD '())(register-provider! history-test: #{ git-request: (lambda (source verb args ready error) (set! REQUESTS (append REQUESTS (list (list (dict-ref source host: #f) verb args)))) (set! HELD (append HELD (list ready)))) })(register-root! history: #{ provider: history-test: host: "remote-a" class: machine: })(define (model) (set! REQUESTS '()) (set! HELD '()) (make-git-log-model (root->source history: dir: "/same/repo") REPO #f))(define (commit oid parents) #{ oid: oid subject: "Subject" parents: parents })(define (page commits offset cursor) #{ version: 1 repo: REPO snapshot: SNAP commits: commits offset: offset next-cursor: cursor complete: (not cursor) })(test "history opens asynchronously and only requests its selected root" (let ((m (model))) (git-log-model-load! m) (assert-equal loading: (dict-ref (git-log-model-state m) state: #f)) (assert-equal "remote-a" (caar REQUESTS)) (assert-equal "git/log" (cadar REQUESTS)) ((car HELD) (page (list (commit OID-A (list OID-B)) (commit OID-B '())) 0 #f)) (let* ((v #{ slate/git-log-model: m }) (next (git-log-move v 1))) (assert-equal OID-B (dict-ref (git-log-selected next) oid: #f)) (assert-equal 1 (length REQUESTS)))))(test "held older page coalesces and replaces the current window" (let ((m (model))) (git-log-model-load! m) ((car HELD) (page (list (commit OID-A '())) 0 #{ head-oid: OID-A offset: 50 })) (git-log-model-page! m 1) (git-log-model-page! m 1) (assert-equal 2 (length REQUESTS)) ((cadr HELD) (page (list (commit OID-B '())) 50 #f)) (assert-equal 1 (length (git-log-commits m))) (assert-equal OID-B (dict-ref (car (git-log-commits m)) oid: #f)) (git-log-model-page! m -1) (assert-equal 0 (dict-ref (dict-ref (caddr (list-ref REQUESTS 2)) cursor: #{}) offset: -1))))(test "refresh rejects out-of-order pages and close rejects late replies" (let ((m (model))) (git-log-model-load! m) (git-log-model-load! m) ((car HELD) (page (list (commit OID-A '())) 0 #f)) (assert-equal loading: (dict-ref (git-log-model-state m) state: #f)) (git-log-model-dispose! m) ((cadr HELD) (page (list (commit OID-B '())) 0 #f)) (assert-equal '() (git-log-commits m))))(test "same paths on the wrong root cannot supply history" (let ((m (model))) (git-log-model-load! m) ((car HELD) (dict-set (page (list (commit OID-A '())) 0 #f) repo: (dict-set REPO root: "other-root"))) (assert-equal error: (dict-ref (git-log-model-state m) state: #f))))(test "inspection is lazy and late metadata cannot start an obsolete patch" (let ((m (model))) (git-log-model-inspect! m OID-A) (git-log-model-inspect! m OID-B) (assert-equal 1 (length REQUESTS)) ((car HELD) #{ version: 1 repo: REPO snapshot: SNAP commit: (commit OID-A '()) }) (assert-equal 2 (length REQUESTS)) ((cadr HELD) #{ version: 1 repo: REPO snapshot: SNAP commit: (commit OID-B '()) }) (assert-equal "git/diff" (cadr (list-ref REQUESTS 2))) (assert-equal OID-B (dict-ref (caddr (list-ref REQUESTS 2)) target: #f))))(test "wrong-root show reply never starts a patch" (let ((m (model))) (git-log-model-inspect! m OID-A) ((car HELD) #{ version: 1 repo: (dict-set REPO root: "other-root") snapshot: SNAP commit: (commit OID-A '()) }) (assert-equal 1 (length REQUESTS)) (assert-equal error: (dict-ref (dict-ref (git-log-model-state m) review: #{}) state: #f))))(test "merge lanes connect both parents and converge" (let ((rows (git-log-graph (list (commit OID-A (list OID-B OID-C)) (commit OID-B (list OID-C)) (commit OID-C '()))))) (assert-equal 2 (length (dict-ref (car rows) edges: '()))) (assert-equal 1 (dict-ref (cadr rows) after: -1)) (assert-equal 0 (dict-ref (caddr rows) after: -1))))(import (slate modes git-log))(test "history shell and loaded rows render through the shipped mode" (let ((m (model))) (git-log-model-load! m) (assert-true (pair? (git-log-render #{ slate/git-log-model: m }))) ((car HELD) (page (list (commit OID-A (list OID-B))) 0 #f)) (assert-true (pair? (git-log-render #{ slate/git-log-model: m })))))(test "oversized history pages are rejected before mounting rows" (let ((m (model))) (git-log-model-load! m) ((car HELD) (page (make-list 51 (commit OID-A '())) 0 #f)) (assert-equal error: (dict-ref (git-log-model-state m) state: #f)) (assert-equal '() (git-log-commits m))))(test "closing a queued inspection cannot start another metadata request" (let ((m (model))) (git-log-model-inspect! m OID-A) (git-log-model-inspect! m OID-B) (git-log-model-dispose! m) ((car HELD) #{ version: 1 repo: REPO snapshot: SNAP commit: (commit OID-A '()) }) (assert-equal 1 (length REQUESTS))))(test "ref refresh preserves the selected history page and open review" (let ((m (model))) (git-log-model-load! m) ((car HELD) (page (list (commit OID-A '())) 50 #f)) (git-log-model-inspect! m OID-A) (let ((review (dict-ref (git-log-model-state m) review: #f))) (git-log-model-refresh! m) (assert-equal 50 (dict-ref (dict-ref (caddr (car (reverse REQUESTS))) cursor: #{}) offset: 0)) ((car (reverse HELD)) (page (list (commit OID-A '())) 50 #f)) (assert-true (eq? review (dict-ref (git-log-model-state m) review: #f))))))packages/slate/test/git-mutation-browser-gate.mjsadded
import { spawnSync } from 'node:child_process';const gate = new URL('./git-status-browser-gate.mjs', import.meta.url);const result = spawnSync(process.execPath, [gate.pathname], { cwd: process.cwd(), env: { ...process.env, B1_MUTATION_GATE: '1' }, encoding: 'utf8'});process.stdout.write(result.stdout || '');process.stderr.write(result.stderr || '');process.exit(result.status ?? 1);packages/slate/test/git-navigation-browser-gate.mjsadded
// Real keyboard navigation through a paged HTML patch, including row groups.import assert from 'node:assert/strict';import fs from 'node:fs';export async function navigationGate({tab, send, sgl, evalIn, waitFor, key, sentFrames, diffReleaseFile}) { if (process.env.GIT_MEMO_GATE === '1') await memoBoundaryGate({tab, sgl, evalIn}); fs.writeFileSync(diffReleaseFile, 'release\n'); await send('Emulation.setDeviceMetricsOverride', {width:1280, height:900, deviceScaleFactor:1, mobile:false}, tab.sessionId); const dispatch = command => sgl(`(slate-dispatch ${JSON.stringify(command)})`); const expand = await evalIn(tab, `[...document.querySelectorAll('.git-unstaged')] .find(e=>e.textContent.includes('navigation.html'))?.querySelector('.git-entry-toggle')?.getAttribute('data-slate-click')`); assert.ok(expand, 'HTML fixture is absent'); await dispatch(expand); await waitFor(tab, `document.querySelectorAll('.slate-diff-row').length > 300`, 'HTML diff page'); const more = await evalIn(tab, `document.querySelector('.slate-diff-continuation')?.getAttribute('data-slate-click')`); assert.ok(more, 'large HTML diff has no continuation'); await dispatch(more); await waitFor(tab, `document.querySelectorAll('.slate-diff-row').length > 650`, 'second HTML diff page'); if (process.env.GIT_NAVIGATION_BASELINE !== '1') { assert.ok(await evalIn(tab, `(() => { const groups=[...document.querySelectorAll('.slate-diff-control > [role="rowgroup"]')]; return groups.length > 1 && groups.every(g=>g.children.length <= 32); })()`), 'diff sibling fanout is unbounded'); } const requestCount = sentFrames.filter(f=>f.includes('git/diff')).length; for (const layout of ['split', 'inline']) { await dispatch(`git-layout:${layout}`); const commands = await evalIn(tab, `[...document.querySelectorAll('.slate-diff-row')] .map(e=>e.getAttribute('data-slate-click'))`); // Cross a row-group boundary, then return across it with real n/p events. await dispatch(commands[28]); await evalIn(tab, `window.__navigationRows=[...document.querySelectorAll('.slate-diff-row')]; true`); const durations=[]; let point=28; for (let i=0;i<24;i++) { const direction=i<12 ? 1 : -1; const started=performance.now(); await key(direction>0 ? 'n' : 'p'); durations.push(performance.now()-started); point+=direction; assert.equal(await evalIn(tab, `document.querySelector('.slate-diff-row.selected')?.getAttribute('data-slate-click')`), commands[point], 'keyboard point skipped or repeated a line'); } assert.ok(await evalIn(tab, `window.__navigationRows.every((row,i)=> row===document.querySelectorAll('.slate-diff-row')[i])`), 'navigation replaced unchanged diff nodes'); const sorted=[...durations].sort((a,b)=>a-b); console.log(JSON.stringify({navigation:layout, rows:commands.length, medianMs:sorted[Math.floor(sorted.length/2)], p95Ms:sorted[Math.floor(sorted.length*0.95)]})); } assert.equal(sentFrames.filter(f=>f.includes('git/diff')).length, requestCount, 'navigation fetched a diff');}// Exercise the actual lazy renderer, including how much row preparation runs.// Keep this root outside Slate's frame and remove it before the navigation gate.async function memoBoundaryGate({tab, sgl, evalIn}) { await evalIn(tab, `(() => {const root=document.createElement('div'); root.id='diff-memo-gate'; document.body.append(root); return true;})()`); try { await sgl(`(begin (import (sigil web client) (slate components diff)) (define memo-prepared 0) (define memo-point "31") (define memo-revision 1) (define memo-rows (let loop ((i 0) (out '())) (if (= i 70) (reverse out) (loop (+ i 1) (cons #{ id: (number->string i) kind: addition: text: "+line" } out))))) (define (memo-draw) (set! memo-prepared 0) (web-client-update "#diff-memo-gate" (lambda () (diff-control-render memo-rows memo-point #f revision: memo-revision prepare-row: (lambda (row) (set! memo-prepared (+ memo-prepared 1)) row)))) (number->string memo-prepared)))`); assert.equal(await sgl('(memo-draw)'), '70', 'initial groups were not produced'); assert.equal(await sgl('(memo-draw)'), '0', 'unchanged groups were reconstructed'); assert.equal(await sgl('(begin (set! memo-point "32") (memo-draw))'), '64', 'crossing a group boundary did not update exactly two groups'); assert.equal(await sgl('(begin (set! memo-point "33") (memo-draw))'), '32', 'moving within a group rebuilt unrelated rows'); assert.equal(await sgl('(begin (set! memo-revision 2) (memo-draw))'), '70', 'changed content did not invalidate memoized groups'); assert.ok(await evalIn(tab, `!document.querySelector('#diff-memo-gate [memo], #diff-memo-gate [key]')`), 'memo metadata leaked into DOM attributes'); await sgl('(web-client-clear "#diff-memo-gate")'); assert.equal(await sgl('(memo-draw)'), '70', 'cleared groups reused obsolete DOM records'); console.log(JSON.stringify({memoBoundaries:'PASS', unchangedRowsPrepared:0, withinGroup:32, acrossGroups:64})); } finally { await sgl('(web-client-clear "#diff-memo-gate")'); await evalIn(tab, `document.getElementById('diff-memo-gate')?.remove(); true`); }}packages/slate/test/git-provider-routing-test.sgladded
;;; A1 provider/root parity: one operation contract, distinct destinations.(import (sigil core) (sigil test) (slate provider) (slate root))(define DESTINATIONS '())(define (recording-op destination) (lambda (source verb args on-ready on-error) (set! DESTINATIONS (append DESTINATIONS (list destination))) (on-ready #{ version: 1 semantic: "same" verb: verb })))(register-provider! git-local-test: #{ git-request: (recording-op "node-local") })(register-provider! git-remote-test: #{ git-request: (recording-op "node-remote") })(register-root! git-local-root: #{ provider: git-local-test: class: local: })(register-root! git-remote-root: #{ provider: git-remote-test: host: "remote" class: machine: })(test "local and remote roots return identical semantics on distinct nodes" (set! DESTINATIONS '()) (let* ((results '()) (ready (lambda (value) (set! results (append results (list value)))))) (source-git-request (root->source git-local-root: dir: "/same/repo") "git/status" #{} ready (lambda (e) (error e))) (source-git-request (root->source git-remote-root: dir: "/same/repo") "git/status" #{} ready (lambda (e) (error e))) (assert-equal (car results) (cadr results)) (assert-equal (list "node-local" "node-remote") DESTINATIONS)))packages/slate/test/git-review-browser-gate.mjsadded
// Real transport and post-boot rendering checks for Git review continuity.import assert from 'node:assert/strict';import fs from 'node:fs';import path from 'node:path';export async function reviewContinuityGate({tab, send, sgl, evalIn, waitFor, key, sentFrames, repo, diffReleaseFile, heldFile, releaseFile, sleep, runGit}) { fs.writeFileSync(diffReleaseFile, 'release\n'); await send('Emulation.setDeviceMetricsOverride', {width:1280, height:900, deviceScaleFactor:1, mobile:false}, tab.sessionId); await sgl('(slate-dispatch "git-layout:split")'); const dispatch = command => sgl(`(slate-dispatch ${JSON.stringify(command)})`); const entry = (file, side = 'unstaged') => `[...document.querySelectorAll('.git-${side}')].find(e=>e.querySelector('.git-entry-path')?.textContent===${JSON.stringify(file)})`; const review = (file, side = 'unstaged') => `(${entry(file, side)})?.nextElementSibling`; const expandSection = async side => { await evalIn(tab, `document.querySelector('.git-section-toggle[aria-expanded="false"][data-slate-click="git-select-toggle:section:${side}"]')?.click()`); }; const choose = async (file, side = 'unstaged') => { await expandSection(side); const command = await evalIn(tab, `(${entry(file, side)})?.getAttribute('data-slate-click')`); assert.ok(command, `missing ${side} file ${file}`); await dispatch(command); }; const open = async (file, side = 'unstaged') => { await choose(file, side); if (await evalIn(tab, `(${entry(file, side)})?.querySelector('.git-entry-toggle')?.getAttribute('aria-expanded') !== 'true'`)) await key('Tab'); await waitFor(tab, `(${review(file, side)})?.querySelector('.slate-diff-mutation') && !(${review(file, side)})?.querySelector('.git-review-refresh')`, `ready review ${side}/${file}`); assert.equal(await evalIn(tab, `(${review(file, side)})?.querySelector('.slate-diff-mutation')?.title`), side === 'staged' ? 'Unstage hunk' : 'Stage hunk', 'hunk label disagrees with its action'); }; const requests = file => sentFrames.filter(frame => frame.includes('git/diff') && frame.includes(`"target":"${file}"`) && !frame.includes('"cancel":true')).length; const hunks = file => `(${review(file)})?.querySelectorAll('.slate-diff-hunk').length`; const hunkCommand = (file, side, index = 0) => evalIn(tab, `(${review(file, side)})?.querySelectorAll('.slate-diff-hunk')[${index}]?.getAttribute('data-slate-click')?.replace('git-select-toggle:', 'git-select:')`); const isExpanded = file => `(${entry(file)})?.querySelector('.git-entry-toggle')?.getAttribute('aria-expanded') === 'true'`; assert.ok(await evalIn(tab, `document.querySelector('.mode-line')?.textContent.includes('1 staged')`), 'untracked entries were counted as staged'); await open('large-diff.sgl'); await dispatch(await hunkCommand('large-diff.sgl', 'unstaged')); await key('Tab'); const largeRequests = requests('large-diff.sgl'); const foldedLarge = `(${review('large-diff.sgl')})?.querySelector('.slate-diff-hunk-toggle')?.getAttribute('aria-expanded') === 'false'`; assert.ok(await evalIn(tab, foldedLarge), 'large hunk did not initially fold'); await open('hunks.sgl'); await waitFor(tab, `${hunks('hunks.sgl')} === 2`, 'two initial hunks'); const initialRequests = requests('hunks.sgl'); await dispatch(await hunkCommand('hunks.sgl', 'unstaged')); await key('s'); await waitFor(tab, `${hunks('hunks.sgl')} === 1 && !(${review('hunks.sgl')})?.querySelector('.git-review-refresh') && (${review('hunks.sgl')})?.querySelector('.slate-diff-hunk.selected')`, 'stage keeps next hunk selected and open'); assert.equal(requests('hunks.sgl'), initialRequests + 1, 'stage did not refresh only the affected open side'); assert.equal(requests('large-diff.sgl'), largeRequests, 'stage eagerly reloaded an unrelated review'); assert.ok(await evalIn(tab, isExpanded('large-diff.sgl')), 'unrelated file collapsed'); assert.ok(await evalIn(tab, foldedLarge), 'unrelated hunk unfolded'); assert.equal(await evalIn(tab, `(${review('large-diff.sgl')})?.querySelectorAll('.slate-diff-mutation').length`), 0, 'retained stale patch still has mutation controls'); await open('hunks.sgl', 'staged'); await dispatch(await hunkCommand('hunks.sgl', 'staged')); await key('u'); await waitFor(tab, `!(${entry('hunks.sgl', 'staged')}) && ${hunks('hunks.sgl')} === 2 && !(${review('hunks.sgl')})?.querySelector('.git-review-refresh')`, 'unstage preserves the open worktree review'); assert.equal(requests('large-diff.sgl'), largeRequests, 'unstage reloaded an unrelated review'); await choose('hunks.sgl'); await dispatch(await hunkCommand('hunks.sgl', 'unstaged')); await key('k'); await waitFor(tab, `document.querySelector('.confirm-panel')`, 'discard hunk confirmation'); await key('y'); await waitFor(tab, `${hunks('hunks.sgl')} === 1 && (${review('hunks.sgl')})?.querySelector('.slate-diff-hunk.selected') && !(${review('hunks.sgl')})?.querySelector('.git-review-refresh')`, 'discard preserves the next hunk'); const text = fs.readFileSync(path.join(repo, 'hunks.sgl'), 'utf8'); assert.ok(!text.includes('hunk changed first') && text.includes('hunk changed second'), 'discard changed the wrong hunk'); assert.ok(await evalIn(tab, foldedLarge), 'discard changed another hunk fold'); assert.equal(requests('large-diff.sgl'), largeRequests); // Returning to a retained review refreshes it once; layout changes remain pure. await choose('large-diff.sgl'); await waitFor(tab, `!(${review('large-diff.sgl')})?.querySelector('.git-review-refresh')`, 'deferred review refresh'); assert.equal(requests('large-diff.sgl'), largeRequests + 1); assert.ok(await evalIn(tab, foldedLarge), 'deferred refresh lost the hunk fold'); await dispatch(await hunkCommand('large-diff.sgl', 'unstaged')); await key('Tab'); await evalIn(tab, `(${review('large-diff.sgl')}).scrollTop=460`); const anchor = await evalIn(tab, `(() => { const port=${review('large-diff.sgl')}, r=port.getBoundingClientRect(); const row=document.elementFromPoint(r.left+r.width/2,r.top+44)?.closest('.slate-diff-row'); return {text:row?.querySelector('.slate-diff-code')?.textContent, offset:row?.getBoundingClientRect().top-r.top}; })()`); assert.ok(anchor.text, 'scrolled review has no visible anchor'); await key('g'); await waitFor(tab, `!document.querySelector('.mode-line')?.textContent.includes('refreshing') && !(${review('large-diff.sgl')})?.querySelector('.git-review-refresh')`, 'refresh keeps review position'); const offset = await evalIn(tab, `(() => { const port=${review('large-diff.sgl')}; const row=[...port.querySelectorAll('.slate-diff-row')].find(row=>row.querySelector('.slate-diff-code')?.textContent===${JSON.stringify(anchor.text)}); return row?.getBoundingClientRect().top-port.getBoundingClientRect().top; })()`); assert.ok(Math.abs(offset-anchor.offset)<4, `review scrolled after refresh: ${anchor.offset} -> ${offset}`); await sgl('(slate-dispatch "git-layout:inline")'); await send('Emulation.setDeviceMetricsOverride', {width:390, height:844, deviceScaleFactor:1, mobile:true}, tab.sessionId); await choose('hunks.sgl'); assert.ok(await evalIn(tab, isExpanded('hunks.sgl')), 'mobile layout lost the surviving review'); await dispatch(await hunkCommand('hunks.sgl', 'unstaged')); await key('s'); await waitFor(tab, `!(${entry('hunks.sgl')}) && (${entry('hunks.sgl', 'staged')})`, 'mobile file leaves source section'); const selected = await evalIn(tab, `document.querySelector('.git-entry.selected')?.querySelector('.git-entry-path')?.textContent`); assert.equal(selected, 'large-diff.sgl', 'completed file did not continue at the next file'); // Hold the real node's next status read inside a file mutation. Navigation // must remain live, and completing that mutation must honor the new point. await waitFor(tab, `!(${review('large-diff.sgl')})?.querySelector('.git-review-refresh')`, 'settled review before held mutation'); await choose('untracked-exact.sgl', 'untracked'); fs.rmSync(heldFile, {force:true}); fs.rmSync(releaseFile); try { await key('s'); for (let i = 0; i < 600 && !fs.existsSync(heldFile); i++) await sleep(50); assert.ok(fs.existsSync(heldFile), 'file mutation never reached its held status read'); await choose('visit-target.sgl'); await key('n'); await key('p'); assert.equal(await evalIn(tab, `document.querySelector('.git-entry.selected')?.querySelector('.git-entry-path')?.textContent`), 'visit-target.sgl', 'navigation stopped during a held mutation'); fs.writeFileSync(releaseFile, 'release\n'); await waitFor(tab, `(${entry('untracked-exact.sgl', 'staged')})`, 'held mutation completed'); assert.equal(await evalIn(tab, `document.querySelector('.git-entry.selected')?.querySelector('.git-entry-path')?.textContent`), 'visit-target.sgl', 'mutation completion stole the later selection'); } finally { fs.writeFileSync(releaseFile, 'release\n'); } runGit(['diff', '--check']); return true;}packages/slate/test/git-review-command-test.sgladded
(import (sigil test) (slate command) (slate commands git) (slate git status-view) (slate git log-view) (slate modes git-log) (slate modes git-status) (slate pane) (slate provider) (slate root) (slate space) (slate state) (slate view))(define STATE #{})(define REQUESTS '())(define HELD '())(define CONFIRM #f)(define REPO #{ root: "review-test" worktree: "/repo" common-dir: "/repo/.git" })(define SNAP #{ head-oid: "h" index-id: "i" worktree-id: "w" })(define FRESH #{ head-oid: "h" index-id: "fresh" worktree-id: "w" })(define (entry path id index work) #{ id: id path: path kind: "ordinary" index-state: index worktree-state: work })(define ENTRIES (list (entry "a.sgl" "old-a" "." "M") (entry "b.sgl" "old-b" "." "M") (entry "c.sgl" "old-c" "." "M")))(define (status entries snapshot) #{ version: 1 repo: REPO snapshot: snapshot branch: #{} entries: entries recent-commits: '() capabilities: #{} })(define (request source verb args ready fail) (set! REQUESTS (append REQUESTS (list (dict-set args verb: verb)))) (cond ((string=? verb "git/discover") (ready #{ version: 1 node-repo: REPO bare: #f object-format: "sha1" })) ((string=? verb "git/status") (ready (status ENTRIES SNAP))) ((dict-ref args cancel: #f) #t) (else (set! HELD (append HELD (list ready))))))(register-provider! review-test: #{ git-request: request subscribe: (lambda (source callback) (lambda () #t)) })(register-root! review-test: #{ provider: review-test: class: machine: })(define (run command) (set! STATE (command-run STATE command)))(define (setup!) (set! REQUESTS '()) (set! HELD '()) (set! CONFIRM #f) (let ((plain (view slate/name: "plain" slate/source: (root->source review-test: file: "/repo/a.sgl")))) (set! STATE #{ slate/views: (list plain) slate/spaces: (list (make-space "main" (make-leaf 0 "plain") 0 1)) slate/active-space: "main" })) (configure-git-commands! #{ state: (lambda () STATE) set-state: (lambda (state) (set! STATE state)) refresh: (lambda () #t) close-view: (lambda (state) (let ((name (view-name (active-view state)))) (focus-view (dict-set state slate/views: (filter (lambda (v) (not (equal? name (view-name v)))) (views state))) "plain"))) confirm: (lambda (state question reply) (set! CONFIRM reply) state) }) (run "git-status"))(define (file-row path section) (find (lambda (row) (and (eq? (dict-ref row kind: #f) entry:) (eq? (dict-ref row section: #f) section) (equal? path (dict-ref (dict-ref row entry: #{}) path: #f)))) (git-status-rows (active-view STATE))))(define (choose path section) (run (string-append "git-select:" (dict-ref (file-row path section) id: ""))))(define (reply-diff ready path snapshot) (ready #{ version: 1 repo: REPO snapshot: snapshot payload-bytes: 500 complete: #t files: (list #{ hunks: (list #{ kind: hunk: text: "@@ -1 +1 @@" } #{ kind: addition: text: (string-append "+" path) }) }) }))(define (open-review path) (choose path unstaged:) (run "git-toggle") (reply-diff (car (reverse HELD)) path SNAP))(define (diff-count) (length (filter (lambda (args) (and (equal? "git/diff" (dict-ref args verb: #f)) (not (dict-ref args cancel: #f)))) REQUESTS)))(test "mutation refreshes affected reviews and preserves unrelated open folds without fetching them" (setup!) (open-review "b.sgl") (run "git-next") (run "git-toggle") (let ((folds (dict-ref (active-view STATE) slate/git-hunk-folded: '()))) (open-review "a.sgl") (choose "a.sgl" unstaged:) (run "git-stage") (let ((reply (car (reverse HELD)))) (choose "c.sgl" unstaged:) (reply (status (list (entry "a.sgl" "new-a" "M" "M") (entry "b.sgl" "new-b" "." "M") (entry "c.sgl" "new-c" "." "M")) FRESH))) (assert-equal "c.sgl" (dict-ref (dict-ref (git-status-selected-row (active-view STATE)) entry: #{}) path: #f)) (assert-equal 2 (length (dict-ref (active-view STATE) slate/git-inline-models: '()))) (assert-equal folds (dict-ref (active-view STATE) slate/git-hunk-folded: '())) (assert-equal 3 (diff-count)) (choose "b.sgl" unstaged:) (assert-equal 4 (diff-count)) (choose "b.sgl" unstaged:) (assert-equal 4 (diff-count)) (reply-diff (car (reverse HELD)) "b.sgl" FRESH) (assert-equal folds (dict-ref (active-view STATE) slate/git-hunk-folded: '()))))(test "stale hunk commands never broaden into file discard and closed reviews stay closed" (setup!) (open-review "b.sgl") (run "git-next") (let ((hunk-id (dict-ref (git-status-selected-row (active-view STATE)) id: ""))) (choose "a.sgl" unstaged:) (run "git-stage") ((car (reverse HELD)) (status (list (entry "a.sgl" "new-a" "M" ".") (entry "b.sgl" "new-b" "." "M") (entry "c.sgl" "new-c" "." "M")) FRESH)) (let ((before (length REQUESTS))) (run (string-append "git-select-discard:" hunk-id)) (assert-false CONFIRM) (assert-equal before (length REQUESTS))) (choose "b.sgl" unstaged:) (let ((reply (car (reverse HELD)))) (run "git-toggle") (reply-diff reply "b.sgl" FRESH) (assert-equal '() (dict-ref (active-view STATE) slate/git-inline-models: '())))))(test "discard confirmation cannot reuse a newer snapshot" (setup!) (choose "a.sgl" unstaged:) (run "git-discard") (assert-true (procedure? CONFIRM)) (let ((model (git-status-model (active-view STATE)))) (vector-set! model 1 #{ state: ready: data: (status ENTRIES FRESH) }) (CONFIRM STATE #t) (assert-false (any (lambda (args) (equal? "git/discard-file" (dict-ref args verb: #f))) REQUESTS))))(test "history quit returns to its opener while a page is held" (setup!) (let ((opener (view-name (active-view STATE)))) (run "git-log") (assert-true (vector? (git-log-model (active-view STATE)))) (let ((count (length REQUESTS))) (run "git-log-refresh") (assert-equal count (length REQUESTS))) (run "git-log-quit") (assert-equal opener (view-name (active-view STATE)))))(test "history clicks load once and review keys retain commit selection" (setup!) (run "git-log") (let* ((oid (make-string 40 #\a)) (other (make-string 40 #\b)) (commit #{ oid: oid subject: "First" parents: '() })) ((car (reverse HELD)) #{ version: 1 repo: REPO snapshot: SNAP offset: 0 complete: #t commits: (list commit #{ oid: other subject: "Second" parents: '() }) }) (run (string-append "git-log-open:" oid)) (assert-equal "git/show" (dict-ref (car (reverse REQUESTS)) verb: #f)) (assert-equal 'list (dict-ref (active-view STATE) slate/git-log-focus: #f)) (let ((before (length REQUESTS))) (run "git-log-inspect") (run "git-log-next") (assert-equal 'review (dict-ref (active-view STATE) slate/git-log-focus: #f)) (assert-equal "line-down" (dict-ref (active-view STATE) slate/git-log-scroll: #f)) (assert-equal oid (dict-ref (git-log-selected (active-view STATE)) oid: #f)) (run "git-log-page-down") (assert-equal 2 (dict-ref (active-view STATE) slate/git-log-scroll-revision: 0)) (run "git-log-switch-pane") (run "git-log-next") (assert-equal other (dict-ref (git-log-selected (active-view STATE)) oid: #f)) (assert-equal before (length REQUESTS))) (run "git-log-quit")))(configure-git-commands! #{})(test-summary)packages/slate/test/git-status-browser-gate.mjsadded
// A3 live gate: real Slate node transport renders this repository's Git status.import assert from 'node:assert/strict';import fs from 'node:fs';import os from 'node:os';import path from 'node:path';import net from 'node:net';import {reviewContinuityGate} from './git-review-browser-gate.mjs';import {branchGate} from './git-branch-browser-gate.mjs';import {historyGate} from './git-log-browser-gate.mjs';import {navigationGate} from './git-navigation-browser-gate.mjs';import { spawn, spawnSync } from 'node:child_process';const workspace = process.env.A3_WORKSPACE || process.cwd();const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));// The local node's owner grant covers the home tree. Keep the disposable Git// fixture there so the browser gate exercises the same fs-write authorization// as a real owner session instead of relying on test-only grant injection.const fixture = fs.mkdtempSync(path.join(os.homedir(), '.slate-git-status-fixture-'));const shimDir = path.join(fixture, 'bin');const repo = path.join(fixture, 'repo');const heldFile = path.join(fixture, 'status-held');const releaseFile = path.join(fixture, 'status-release');const branchListReleaseFile = path.join(fixture, 'branch-list-release');const branchSwitchReleaseFile = path.join(fixture, 'branch-switch-release');const historyReleaseFile = path.join(fixture, 'history-release');const diffHeldFile = path.join(fixture, 'diff-held');const diffReleaseFile = path.join(fixture, 'diff-release');fs.mkdirSync(shimDir);fs.mkdirSync(repo);const git = spawnSync('which', ['git'], { encoding: 'utf8' }).stdout.trim();const runGit = args => { const result = spawnSync(git, args, { cwd: repo, encoding: 'utf8' }); assert.equal(result.status, 0, `fixture git ${args.join(' ')}: ${result.stderr}`); return result.stdout;};runGit(['init', '-q', '--initial-branch=main']);runGit(['config', 'user.name', 'Slate Gate']);runGit(['config', 'user.email', 'slate-gate@example.invalid']);fs.writeFileSync(path.join(repo, 'visit-target.sgl'), 'base\n');if (process.env.GIT_NAVIGATION_GATE === '1') { fs.writeFileSync(path.join(repo, 'navigation.html'), '<!doctype html>\n'); runGit(['add', '--', 'navigation.html']);}fs.writeFileSync(path.join(repo, 'hunks.sgl'), Array.from({length: 100}, (_, i) => `hunk base ${String(i).padStart(3, '0')}`).join('\n') + '\n');fs.writeFileSync(path.join(repo, 'large-diff.sgl'), Array.from({length: 160000}, (_, i) => `before ${String(i).padStart(6, '0')}`).join('\n') + '\n');runGit(['add', '--', 'visit-target.sgl', 'large-diff.sgl', 'hunks.sgl']);runGit(['commit', '-q', '-m', 'fixture base']);if (process.env.GIT_HISTORY_GATE === '1') { for (let i=0;i<55;i++) runGit(['commit','-q','--allow-empty','-m',`History checkpoint ${i}`]); fs.writeFileSync(path.join(repo,'history.txt'),'history inspection marker\n'+Array.from({length:140},(_,i)=>`review line ${i+1}\n`).join('')); runGit(['add','--','history.txt']); runGit(['commit','-q','-m','Add history inspection','-m','Inspect the **committed change** without losing your place.\n\n- Preserve selection\n- Render `code` clearly\n\n```sh\ngit log --oneline\n```']);}if (process.env.GIT_NAVIGATION_GATE === '1') fs.appendFileSync(path.join(repo, 'navigation.html'), Array.from({length:1600}, (_, i) => `<div class="review-row" data-index="${i}"><span>${'HTML review content '.repeat(i % 40 === 0 ? 100 : 3)}</span></div>`).join('\n') + '\n');fs.writeFileSync(path.join(repo, 'visit-target.sgl'), `base\nunstaged exact\nlong exact ${'x'.repeat(3000)}\n`);const hunkLines = Array.from({length: 100}, (_, i) => `hunk base ${String(i).padStart(3, '0')}`);hunkLines[3] = 'hunk changed first';hunkLines[70] = 'hunk changed second';fs.writeFileSync(path.join(repo, 'hunks.sgl'), hunkLines.join('\n') + '\n');fs.writeFileSync(path.join(repo, 'large-diff.sgl'), Array.from({length: 160000}, (_, i) => `after ${String(i).padStart(6, '0')}`).join('\n') + '\n');fs.writeFileSync(path.join(repo, 'staged-exact.sgl'), 'staged exact\n');runGit(['add', '--', 'staged-exact.sgl']);fs.writeFileSync(path.join(repo, 'untracked-exact.sgl'), 'untracked exact\n');for (let i = 0; i < 80; i++) fs.writeFileSync(path.join(repo, `untracked-${String(i).padStart(3, '0')}.sgl`), `untracked ${i}\n`);const shim = path.join(shimDir, 'git');fs.writeFileSync(shim, `#!/bin/shhold_status=falsehold_diff=falsehold_history=falsefor arg in "$@"; do if [ "$arg" = status ]; then hold_status=true; fi if [ "$arg" = diff ]; then hold_diff=true; fi if [ "$arg" = --topo-order ]; then hold_history=true; fidoneif [ "$hold_status" = true ] && [ ! -f ${JSON.stringify(releaseFile)} ]; then : > ${JSON.stringify(heldFile)} while [ ! -f ${JSON.stringify(releaseFile)} ]; do sleep 0.05; donefiif [ "$hold_diff" = true ] && [ ! -f ${JSON.stringify(diffReleaseFile)} ]; then : > ${JSON.stringify(diffHeldFile)} while [ ! -f ${JSON.stringify(diffReleaseFile)} ]; do sleep 0.05; donefiif [ "$hold_history" = true ] && [ ! -f ${JSON.stringify(historyReleaseFile)} ]; then while [ ! -f ${JSON.stringify(historyReleaseFile)} ]; do sleep 0.05; donefiif [ "${process.env.GIT_BRANCH_GATE || '0'}" = 1 ]; then for arg in "$@"; do if [ "$arg" = for-each-ref ]; then while [ ! -f ${JSON.stringify(branchListReleaseFile)} ]; do sleep 0.05; done fi if [ "$arg" = switch ]; then while [ ! -f ${JSON.stringify(branchSwitchReleaseFile)} ]; do sleep 0.05; done fi donefiexec ${JSON.stringify(git)} "$@"`);fs.chmodSync(shim, 0o755);const port = await new Promise((resolve, reject) => { const probe = net.createServer(); probe.once('error', reject); probe.listen(0, '127.0.0.1', () => { const selected = probe.address().port; probe.close(error => error ? reject(error) : resolve(selected)); });});const url = `http://127.0.0.1:${port}/`;const slateBin = process.env.A3_SLATE_BIN || path.join(workspace, 'build/dev/bin/slate');const daemon = spawn(slateBin, ['serve', '--app-dir', process.env.A3_APP_DIR || path.join(workspace, 'build/web'), '--port', String(port), '--log-level', 'debug'], { cwd: workspace, env: { ...process.env, PATH: `${shimDir}:${process.env.PATH}`, // Each daemon owns its origin grants and atomic state-file publications. // Sharing the user's state directory makes concurrent gates race each other // and the ordinary preview, even though their repositories and ports differ. XDG_STATE_HOME: path.join(fixture, 'state'), SLATE_NODE_GRANTS: `fs:rw:${fixture},exec:full` }, stdio: ['ignore', 'pipe', 'pipe']});let daemonLog = '';daemon.stdout.on('data', chunk => { daemonLog += chunk; });daemon.stderr.on('data', chunk => { daemonLog += chunk; });for (let i = 0; i < 200; i++) { try { if ((await fetch(url)).ok) break; } catch (_) {} if (i === 199) throw new Error(`Slate daemon did not start: ${daemonLog}`); await sleep(50);}const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'slate-git-status-chrome-'));const chrome = spawn('google-chrome', ['--headless=new', '--no-sandbox', '--disable-gpu', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] });const chromeExit = new Promise(resolve => chrome.once('exit', resolve));let socket;const runtimeMessages = [];const sentFrames = [];const receivedFrames = [];const wireEvents = [];try { const activePort = path.join(profile, 'DevToolsActivePort'); let debugEndpoint; for (let i = 0; i < 200 && !debugEndpoint; i++) { if (fs.existsSync(activePort)) { // File creation can be observed before both endpoint lines are written. const [port, browserPath] = fs.readFileSync(activePort, 'utf8').trim().split('\n'); if (/^\d+$/.test(port) && browserPath?.startsWith('/devtools/browser/')) debugEndpoint = `ws://127.0.0.1:${port}${browserPath}`; } if (!debugEndpoint) await sleep(50); } assert.ok(debugEndpoint, 'Chrome did not publish a complete DevTools endpoint'); socket = new WebSocket(debugEndpoint); await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; }); let id = 0; const pending = new Map(); socket.onmessage = ({ data }) => { const msg = JSON.parse(data); if (msg.method === 'Runtime.consoleAPICalled') runtimeMessages.push(msg.params.args.map(a => a.value ?? a.description).join(' ')); if (msg.method === 'Runtime.exceptionThrown') runtimeMessages.push(msg.params.exceptionDetails.exception?.description || msg.params.exceptionDetails.text); if (msg.method === 'Network.webSocketFrameSent') { sentFrames.push(msg.params.response.payloadData || ''); wireEvents.push({dir:'sent', payload:msg.params.response.payloadData || ''}); } if (msg.method === 'Network.webSocketFrameReceived') { receivedFrames.push(msg.params.response.payloadData || ''); wireEvents.push({dir:'received', payload:msg.params.response.payloadData || ''}); } if (msg.id && pending.has(msg.id)) { const p = pending.get(msg.id); pending.delete(msg.id); msg.error ? p.reject(new Error(msg.error.message)) : p.resolve(msg.result); } }; const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => { const callId = ++id; pending.set(callId, { resolve, reject }); socket.send(JSON.stringify({ id: callId, method, params, ...(sessionId ? { sessionId } : {}) })); }); const makeTab = async () => { const target = await send('Target.createTarget', { url: 'about:blank' }); const attached = await send('Target.attachToTarget', { targetId: target.targetId, flatten: true }); await send('Runtime.enable', {}, attached.sessionId); await send('Page.enable', {}, attached.sessionId); await send('Network.enable', {}, attached.sessionId); await send('Page.navigate', { url: `${url}?rbdebug&git-status-a3=${Date.now()}` }, attached.sessionId); return { targetId: target.targetId, sessionId: attached.sessionId }; }; const evalIn = async (tab, expression) => { const out = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, tab.sessionId); if (out.exceptionDetails) throw new Error(out.exceptionDetails.exception?.description || out.exceptionDetails.text); return out.result.value; }; const waitFor = async (tab, expression, label, rounds = 300) => { for (let i = 0; i < rounds; i++) { if (await evalIn(tab, expression)) return; await sleep(100); } const body = await evalIn(tab, 'document.body.innerText.slice(0, 1600)'); throw new Error(`timeout waiting for ${label}; runtime=${runtimeMessages.join(' | ')}; daemon=${daemonLog.slice(-3000)}; body=${body}`); }; const boot = async tab => { await waitFor(tab, `document.getElementById('status')?.textContent === 'ready'`, 'Slate boot'); await waitFor(tab, `window.slateEval && window.slateEval('(slate-node-connected?)') === '1'`, 'node auth'); await waitFor(tab, `window.slateEval('(slate-preferences-settled?)') === '1'`, 'preference restore'); await waitFor(tab, `!document.getElementById('splash')`, 'interactive app'); }; const tab = await makeTab(); await boot(tab); const sgl = form => evalIn(tab, `window.slateEval(${JSON.stringify(form)})`); const useRemoteRoot = process.env.GIT_HISTORY_REMOTE === '1' || process.env.A4_DIFF_GATE === '1' || process.env.B3_DISCARD_REFRESH_GATE === '1' || process.env.GIT_CONTINUITY_GATE === '1'; const gateRoot = useRemoteRoot ? 'a4remote' : 'local'; // The fixture grant excludes the real home directory, so fs/home cannot // publish the normal local root. Register the granted fixture explicitly on // both routes; keep the real local node transport and narrow test grants. // This synthetic root must not fire local-node arrival hooks: their async // init-file read can return to the startup view after navigation has begun. await sgl(`(begin (import (slate root)) (register-root-silent! ${gateRoot}: #{ provider: local: home: ${JSON.stringify(repo)} host: ${JSON.stringify(useRemoteRoot ? 'fixture-remote' : 'localhost')} label: "Git fixture" class: machine: }))`); await sgl(`(begin (import (slate root)) (register-root! gatebrowse: #{ provider: local: home: ${JSON.stringify(repo)} host: ${JSON.stringify(useRemoteRoot ? 'fixture-remote' : 'localhost')} class: machine: }))`); const openedRoot = await sgl('(slate-dispatch "openroot:gatebrowse")'); assert.ok(!String(openedRoot).includes('Error:'), `root opening failed: ${openedRoot}`); await waitFor(tab, `document.querySelector('.folder') && [...document.querySelectorAll('.folder-row')].some(row => row.textContent.includes('visit-target.sgl'))`, 'root folder listing'); await sgl('(slate-dispatch "open-folder")'); const openedFolder = await sgl(`(slate-dispatch ${JSON.stringify(`openfolder:${shimDir}`)})`); assert.ok(!String(openedFolder).includes('Error:'), `folder path opening failed: ${openedFolder}`); await waitFor(tab, `document.querySelector('.folder') && [...document.querySelectorAll('.folder-stem')].some(row => row.textContent.trim() === 'git')`, 'folder path listing'); await sgl(`(slate-dispatch ${JSON.stringify(`md-visit:${gateRoot}:${path.join(repo, 'visit-target.sgl')}`)})`); await waitFor(tab, `(() => { const editor=document.querySelector('.editor-input, .editor-content'); return editor && (editor.value ?? editor.textContent).includes('unstaged exact'); })()`, 'loaded repository file'); const registered = await sgl('(begin (import (slate command)) (if (command-resolve "git-status") "YES" "NO"))'); assert.ok(registered.includes('YES'), `git-status is absent from runtime registry: ${registered}`); await evalIn(tab, `window.__gitStatusLoadingSeen=false; new MutationObserver(() => { if (document.body.innerText.includes('Loading…')) window.__gitStatusLoadingSeen=true; }) .observe(document.body, {subtree:true, childList:true, characterData:true});`); const key = async (name, modifiers = 0) => { await send('Input.dispatchKeyEvent', { type: 'keyDown', key: name, modifiers }, tab.sessionId); await send('Input.dispatchKeyEvent', { type: 'keyUp', key: name, modifiers }, tab.sessionId); }; await key('x', 2); await key('g'); await waitFor(tab, `document.querySelector('.git-status.loading') && document.body.innerText.includes('Loading…')`, 'held Git loading shell', 100); // Startup and discovery can exceed five seconds on a loaded machine. Wait // for the actual hold marker; elapsed setup time is not a performance gate. for (let i = 0; i < 600 && !fs.existsSync(heldFile); i++) await sleep(50); assert.ok(fs.existsSync(heldFile), 'real node Git status was not held'); assert.equal(await evalIn(tab, 'window.__gitStatusLoadingSeen'), true, 'loading state was not rendered'); await key('ArrowDown'); await key('q'); await waitFor(tab, `!document.querySelector('.git-status')`, 'q while remote status is held'); fs.writeFileSync(releaseFile, 'release\n'); await sleep(1000); assert.equal(await evalIn(tab, `!!document.querySelector('.git-status')`), false, 'late held status reply mutated the closed view'); await key('x', 2); await key('g'); await waitFor(tab, `document.querySelector('.git-status') && document.body.innerText.includes('untracked-exact.sgl')`, 'deterministic Git status view'); for (const exact of ['visit-target.sgl', 'staged-exact.sgl', 'untracked-exact.sgl']) assert.equal(await evalIn(tab, `document.body.innerText.includes(${JSON.stringify(exact)})`), true, `fixture row missing: ${exact}`); assert.equal(await evalIn(tab, `!!document.querySelector('.git-repository-name[aria-label="Repository"]') && !!document.querySelector('.git-repository-origin[aria-label="Root / node"]')`), true, 'repository root/node labels are absent'); if (process.env.B1_MUTATION_GATE === '1') { const frameCount = verb => sentFrames.filter(frame => frame.includes(verb)).length; const clickMutation = async (filename, sectionClass) => { const command = await evalIn(tab, `(() => { const row=[...document.querySelectorAll(${JSON.stringify(sectionClass)})] .find(e => e.textContent.includes(${JSON.stringify(filename)})); const action=row?.querySelector('.git-entry-mutation'); if (!action) return null; return action.getAttribute('data-slate-click'); })()`); assert.ok(command, `missing mutation action for ${filename}`); await sgl(`(slate-dispatch ${JSON.stringify(command)})`); }; const stageBefore = frameCount('git/stage-file'); await clickMutation('untracked-exact.sgl', '.git-entry'); await waitFor(tab, `[...document.querySelectorAll('.git-staged')] .some(e => e.textContent.includes('untracked-exact.sgl'))`, 'pointer file stage'); assert.ok(frameCount('git/stage-file') > stageBefore, 'pointer stage did not use git/stage-file'); const unstageBefore = frameCount('git/unstage-file'); await clickMutation('untracked-exact.sgl', '.git-staged'); await waitFor(tab, `[...document.querySelectorAll('.git-untracked')] .some(e => e.textContent.includes('untracked-exact.sgl'))`, 'pointer file unstage'); assert.ok(frameCount('git/unstage-file') > unstageBefore, 'pointer unstage did not use git/unstage-file'); const keyboardTarget = await evalIn(tab, `(() => { const row=[...document.querySelectorAll('.git-unstaged')] .find(e => e.textContent.includes('visit-target.sgl')); const action=row?.querySelector('.git-entry-mutation')?.getAttribute('data-slate-click'); return action?.replace('git-select-stage:', 'git-select:') || null; })()`); assert.ok(keyboardTarget, 'keyboard mutation target is absent'); await sgl(`(slate-dispatch ${JSON.stringify(keyboardTarget)})`); const keyboardStageBefore = frameCount('git/stage-file'); await key('s'); await waitFor(tab, `[...document.querySelectorAll('.git-staged')] .some(e => e.textContent.includes('visit-target.sgl'))`, 'keyboard file stage'); assert.ok(frameCount('git/stage-file') > keyboardStageBefore, 's did not use git/stage-file'); const stagedTarget = await evalIn(tab, `[...document.querySelectorAll('.git-staged')] .find(e=>e.textContent.includes('visit-target.sgl'))?.getAttribute('data-slate-click')`); await sgl(`(slate-dispatch ${JSON.stringify(stagedTarget)})`); await key('u'); await waitFor(tab, `[...document.querySelectorAll('.git-unstaged')] .some(e => e.textContent.includes('visit-target.sgl'))`, 'keyboard file unstage'); } if (process.env.B3_DISCARD_REFRESH_GATE === '1') { const frameCount = verb => sentFrames.filter(frame => frame.includes(verb)).length; const discardCommand = await evalIn(tab, `(() => { const row=[...document.querySelectorAll('.git-untracked')] .find(e=>e.textContent.includes('untracked-exact.sgl')); return row?.querySelector('.git-entry-discard')?.getAttribute('data-slate-click') || null; })()`); assert.ok(discardCommand, 'untracked pointer discard route missing'); const beforeDecline = frameCount('git/discard-file'); await sgl(`(slate-dispatch ${JSON.stringify(discardCommand)})`); await waitFor(tab, `document.querySelector('.confirm-panel') && document.body.innerText.includes('This cannot be undone')`, 'typed irreversible discard confirmation'); await key('n'); assert.equal(frameCount('git/discard-file'), beforeDecline, 'discard ran after decline'); assert.ok(fs.existsSync(path.join(repo, 'untracked-exact.sgl')), 'decline removed untracked bytes'); await sgl(`(slate-dispatch ${JSON.stringify(discardCommand)})`); await key('y'); await waitFor(tab, `!document.body.innerText.includes('untracked-exact.sgl')`, 'confirmed discard convergence'); assert.ok(frameCount('git/discard-file') > beforeDecline, 'confirmed discard did not use node operation'); const statusBefore = frameCount('git/status'); for (let i = 0; i < 12; i++) fs.writeFileSync(path.join(repo, `storm-${String(i).padStart(2, '0')}.sgl`), `storm ${i}\n`); await waitFor(tab, `document.querySelector('.mode-line')?.textContent.includes('refresh needed')`, 'watch event stale mode line'); await key('n'); await key('p'); assert.equal(frameCount('git/status'), statusBefore, 'passive watch started Git work'); assert.ok(!await evalIn(tab, `document.body.innerText.includes('storm-11.sgl')`), 'passive watch replaced status before user refresh'); fs.rmSync(releaseFile); fs.rmSync(heldFile, {force:true}); await key('g'); await waitFor(tab, `document.querySelector('.mode-line')?.textContent.includes('refreshing')`, 'explicit refresh progress in mode line'); for (let i=0; i<100 && !fs.existsSync(heldFile); i++) await sleep(50); assert.ok(fs.existsSync(heldFile), 'explicit refresh was not held at the node'); const pendingStageCount = frameCount('git/stage-file'); const stageDuringRefresh = await evalIn(tab, `document.querySelector('.git-untracked .git-entry-mutation')?.getAttribute('data-slate-click')`); assert.ok(stageDuringRefresh, 'pending refresh stage fixture is missing'); await sgl(`(slate-dispatch ${JSON.stringify(stageDuringRefresh)})`); assert.equal(frameCount('git/stage-file'), pendingStageCount, 'mutation raced an explicit refresh'); fs.writeFileSync(path.join(repo, 'during-refresh.sgl'), 'newest state\n'); const selectionBefore = await evalIn(tab, `document.querySelector('.git-row.selected')?.textContent`); await key('n'); assert.notEqual(await evalIn(tab, `document.querySelector('.git-row.selected')?.textContent`), selectionBefore, 'navigation blocked during explicit refresh'); await key('p'); await key('g'); assert.equal(frameCount('git/status'), statusBefore + 1, 'excess status calls during explicit refresh'); fs.writeFileSync(releaseFile, 'release\n'); await waitFor(tab, `document.body.innerText.includes('storm-11.sgl') && document.body.innerText.includes('during-refresh.sgl') && !document.querySelector('.mode-line')?.textContent.includes('refreshing')`, 'explicit refresh newest-state convergence'); const statusAfter = frameCount('git/status'); assert.equal(statusAfter, statusBefore + 1, `explicit refresh status calls after event storm: ${statusAfter - statusBefore}`); } if (process.env.B2_HUNK_GATE === '1') { fs.writeFileSync(diffReleaseFile, 'release\n'); const frameCount = verb => sentFrames.filter(frame => frame.includes(verb)).length; assert.equal(frameCount('git/diff'), 0, 'status open eagerly requested a diff'); const pointerClick = async selectorExpression => { const point = await evalIn(tab, `(() => { const el=${selectorExpression}; if(!el) return null; el.scrollIntoView({block:'center'}); const r=el.getBoundingClientRect(); return {x:r.left+r.width/2,y:r.top+r.height/2}; })()`); assert.ok(point, 'hunk pointer target missing'); await send('Input.dispatchMouseEvent', {type:'mousePressed', x:point.x, y:point.y, button:'left', clickCount:1}, tab.sessionId); await send('Input.dispatchMouseEvent', {type:'mouseReleased', x:point.x, y:point.y, button:'left', clickCount:1}, tab.sessionId); }; const unstagedRow = `[...document.querySelectorAll('.git-unstaged')].find(e=>e.textContent.includes('hunks.sgl'))`; await pointerClick(`${unstagedRow}?.querySelector('.git-entry-toggle')`); await waitFor(tab, `document.querySelectorAll('.slate-diff-hunk').length >= 2`, 'two worktree hunks'); const stageBefore = frameCount('git/stage-hunk'); await pointerClick(`document.querySelector('[data-slate-click^="git-select-stage:"][data-slate-click*=":hunk:"]')`); await waitFor(tab, `[...document.querySelectorAll('.git-staged')].some(e=>e.textContent.includes('hunks.sgl'))`, 'pointer hunk stage'); assert.ok(frameCount('git/stage-hunk') > stageBefore, 'pointer hunk stage did not use git/stage-hunk'); const cached = spawnSync(git, ['diff', '--cached', '--', 'hunks.sgl'], {cwd:repo, encoding:'utf8'}).stdout; assert.ok(cached.includes('hunk changed first'), 'selected first hunk was not staged'); assert.ok(!cached.includes('hunk changed second'), 'wrong hunk staged'); const stagedRow = `[...document.querySelectorAll('.git-staged')].find(e=>e.textContent.includes('hunks.sgl'))`; await pointerClick(`${stagedRow}?.querySelector('.git-entry-toggle')`); await waitFor(tab, `document.querySelector('[data-slate-click^="git-select-unstage:"][data-slate-click*=":hunk:"]')`, 'staged hunk expansion'); await pointerClick(`document.querySelector('[data-slate-click^="git-select-unstage:"][data-slate-click*=":hunk:"]') ?.closest('.slate-diff-hunk')`); const unstageBefore = frameCount('git/unstage-hunk'); await key('u'); await waitFor(tab, `![...document.querySelectorAll('.git-staged')].some(e=>e.textContent.includes('hunks.sgl'))`, 'keyboard hunk unstage'); assert.ok(frameCount('git/unstage-hunk') > unstageBefore, 'u did not use git/unstage-hunk'); } assert.equal(await evalIn(tab, `[...document.querySelectorAll('.git-section')] .some(section => /\\(0\\)\\s*$/.test(section.textContent.trim()))`), false, 'zero-count section heading rendered'); const selectedId = () => evalIn(tab, `(() => { const row=document.querySelector('.git-row.selected'); return row?.getAttribute('data-slate-click') || row?.querySelector('[data-slate-click]')?.getAttribute('data-slate-click') || ''; })()`); await key('n'); await waitFor(tab, `document.querySelectorAll('.git-row.selected').length === 1`, 'keyboard selection'); const afterN = await selectedId(); assert.equal(afterN.includes('section:conflicts'), false, 'empty Conflicts section consumed navigation'); await key('p'); assert.notEqual(await selectedId(), afterN, 'p did not navigate backward'); await key('ArrowDown'); assert.equal(await selectedId(), afterN, 'ArrowDown did not alias n'); await key('ArrowUp'); assert.notEqual(await selectedId(), afterN, 'ArrowUp did not alias p'); await evalIn(tab, `document.querySelector('.git-section-toggle').click()`); await evalIn(tab, `document.querySelector('.git-section-toggle').click()`); await key('Tab'); await waitFor(tab, `document.querySelector('.git-section[aria-expanded="false"]')`, 'keyboard folding'); const click = async selector => { await evalIn(tab, `new Promise(resolve => { document.querySelector(${JSON.stringify(selector)}).scrollIntoView({block:'center'}); requestAnimationFrame(() => resolve(true)); })`); const point = await evalIn(tab, `(() => { const el=document.querySelector(${JSON.stringify(selector)}); const r=el.getBoundingClientRect(), x=r.left+r.width/2, y=r.top+r.height/2; const hit=document.elementFromPoint(x,y); return {x,y, hit:hit === el || el.contains(hit), target:hit?.outerHTML.slice(0,300)}; })()`); assert.ok(point.hit, `pointer target is obscured for ${selector}: ${point.target}`); await send('Input.dispatchMouseEvent', {type:'mousePressed', x:point.x, y:point.y, button:'left', clickCount:1}, tab.sessionId); await send('Input.dispatchMouseEvent', {type:'mouseReleased', x:point.x, y:point.y, button:'left', clickCount:1}, tab.sessionId); }; const beforeRefresh = sentFrames.filter(frame => frame.includes('git/status')).length; fs.rmSync(releaseFile); await click('.git-status .git-header-icon[title^="Refresh"]'); await waitFor(tab, `document.querySelector('.mode-line')?.textContent.includes('refreshing')`, 'pointer refresh pending'); fs.writeFileSync(releaseFile, 'release\n'); await waitFor(tab, `!document.querySelector('.mode-line')?.textContent.includes('refreshing')`, 'pointer refresh complete'); assert.ok(sentFrames.filter(frame => frame.includes('git/status')).length > beforeRefresh, 'pointer refresh did not send git/status'); await click('.git-entry .git-entry-path'); await waitFor(tab, `!document.querySelector('.git-status') && document.querySelector('.editor-input, .editor-content')`, 'pointer filename visit', 100); await sgl('(slate-dispatch "git-status")'); await waitFor(tab, `document.querySelector('.git-status') && document.body.innerText.includes('Untracked')`, 'Git status reopen'); await sgl('(slate-dispatch "git-status")'); await waitFor(tab, `document.querySelectorAll('.git-status').length === 1`, 'canonical repository view reuse'); await waitFor(tab, `document.body.innerText.includes('Untracked') && !document.querySelector('.mode-line')?.textContent.includes('refreshing')`, 'canonical view refresh'); for (let i = 0; i < 35; i++) await key('n'); await waitFor(tab, `(() => { const pane=document.querySelector('.pane.focused'); const row=document.querySelector('.git-row.selected'); if (!pane || !row) return false; const vr=pane.getBoundingClientRect(), rr=row.getBoundingClientRect(); return rr.top >= vr.top && rr.bottom <= vr.bottom; })()`, 'Git selection follows viewport', 100); await sgl('(slate-dispatch "git-select:section:untracked")'); const touchExpandedBefore = await evalIn(tab, `[...document.querySelectorAll('.git-section')] .find(e => e.textContent.includes('Untracked'))?.getAttribute('aria-expanded')`); const touchPoint = await evalIn(tab, `(() => { const section = [...document.querySelectorAll('.git-section')].find(e => e.textContent.includes('Untracked')); const el = section?.querySelector('.git-section-toggle'); el.scrollIntoView({block:'center'}); const r = el.getBoundingClientRect(); return {x:r.left+r.width/2, y:r.top+r.height/2}; })()`); await send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [touchPoint] }, tab.sessionId); await send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }, tab.sessionId); await waitFor(tab, `[...document.querySelectorAll('.git-section')] .some(e => e.textContent.includes('Untracked') && e.getAttribute('aria-expanded') !== ${JSON.stringify(touchExpandedBefore)})`, 'touch folding'); const wire = sentFrames.join('\n'); assert.ok(wire.includes('git/discover') && wire.includes('git/status'), 'Git requests did not traverse node WebSocket'); if (process.env.B2_HUNK_GATE !== '1') assert.equal(wire.includes('git/diff'), false, 'status open eagerly requested a diff'); if (process.env.GIT_LAYOUT_GATE === '1') { const framesBefore = sentFrames.filter(frame => frame.includes('git/diff')).length; fs.writeFileSync(diffReleaseFile, 'release\n'); await send('Emulation.setDeviceMetricsOverride', {width:1280,height:900,deviceScaleFactor:1,mobile:false}, tab.sessionId); await sgl('(slate-dispatch "git-select:section:unstaged")'); await evalIn(tab, `(() => { const section=[...document.querySelectorAll('.git-section')].find(e=>e.textContent.includes('Unstaged')); if(section.getAttribute('aria-expanded')==='false')section.querySelector('.git-section-toggle').click(); })()`); await evalIn(tab, `[...document.querySelectorAll('.git-entry')].find(e=>e.textContent.includes('visit-target.sgl')).querySelector('.git-entry-toggle').click()`); await waitFor(tab, `document.querySelector('.git-inline-diff.active .slate-diff-addition') && !document.body.innerText.includes('Loading diff…')`, 'layout review loaded'); assert.equal(sentFrames.filter(frame=>frame.includes('git/diff')).length, framesBefore+1); const expectedText = await evalIn(tab, `document.querySelector('.git-inline-diff.active .slate-diff-control').textContent`); const owner = await evalIn(tab, `document.querySelector('.git-inline-diff.active').getAttribute('data-review-owner')`); const position = `getComputedStyle(document.querySelector('.git-inline-diff.active')).position`; await waitFor(tab, position+` === 'absolute'`, 'wide Auto review split'); const requestsBeforeLayout = sentFrames.filter(frame=>/git\/(diff|status|discover)/.test(frame)).length; const screenshot = async label => { if (!process.env.GIT_UI_SCREENSHOTS) return; if (label === 'inline' || label === 'mobile') { await evalIn(tab, `document.querySelector('.git-inline-diff.active .slate-diff-hunk')?.scrollIntoView({block:'start'})`); await sleep(100);Showing the first 500 of 868 diff lines for this file. This diff is INCOMPLETE; read the file or clone the repository for the rest.
packages/slate/test/git-status-model-test.sgladded
;;; A2 provider-neutral Git status model and repository resolution.(import (sigil core) (sigil test) (slate provider) (slate root) (slate project) (slate git model))(define REQUESTS '())(define HELD '())(define MUTATION-ARGS #f)(define (reply-status source args on-ready) (let ((repo (dict-ref args repo: #{}))) (on-ready #{ version: 1 repo: repo snapshot: #{ head-oid: "abc" index-id: "idx" worktree-id: "wt" } branch: #{} entries: '() recent-commits: '() capabilities: #{} })))(define (fake-git destination held?) (lambda (source verb args on-ready on-error) (when (or (string=? verb "git/stage-hunk") (string=? verb "git/unstage-hunk")) (set! MUTATION-ARGS args)) (set! REQUESTS (append REQUESTS (list (list destination verb (dict-ref args path: #f))))) (let ((run (lambda () (if (string=? verb "git/discover") (on-ready #{ version: 1 node-repo: #{ worktree: "/home/user/repo" common-dir: "/home/user/repo/.git" } bare: #f object-format: "sha1" }) (reply-status source args on-ready))))) (if held? (set! HELD (append HELD (list run))) (run)))))(register-provider! model-local: #{ git-request: (fake-git "local" #f) })(register-provider! model-remote-a: #{ git-request: (fake-git "remote-a" #f) })(register-provider! model-remote-b: #{ git-request: (fake-git "remote-b" #f) })(register-provider! model-held: #{ git-request: (fake-git "held" #t) })(register-provider! model-browser: #{ materialize: (lambda (source) "") })(register-root! model-local: #{ provider: model-local: class: local: })(register-root! model-remote-a: #{ provider: model-remote-a: host: "a" class: machine: })(register-root! model-remote-b: #{ provider: model-remote-b: host: "b" class: machine: })(register-root! model-held: #{ provider: model-held: host: "held" class: machine: })(register-root! model-browser: #{ provider: model-browser: class: browser: })(define (load-model source project) (let ((model (make-git-status-model #f))) (git-status-model-load! model source project) model))(test "project roots and source directories choose discovery path" (set! REQUESTS '()) (let* ((root (root->source model-local: dir: "/workspace/project")) (project (make-project root git: #{}))) (load-model (root->source model-local: file: "/workspace/project/src/a.sgl") project) (load-model (root->source model-local: file: "/outside/src/b.sgl") project) (load-model (root->source model-local: dir: "/folder") #f) (assert-equal "/workspace/project" (caddr (car REQUESTS))) (assert-equal "/outside/src" (caddr (list-ref REQUESTS 2))) (assert-equal "/folder" (caddr (list-ref REQUESTS 4)))))(test "local and remote roots use the same model contract" (let ((local (load-model (root->source model-local: file: "/x") #f)) (remote (load-model (root->source model-remote-a: file: "/x") #f))) (assert-equal ready: (dict-ref (git-status-model-state local) state: #f)) (assert-equal ready: (dict-ref (git-status-model-state remote) state: #f))))(test "same path on different remote roots has no identity collision" (let* ((a (load-model (root->source model-remote-a: file: "/x") #f)) (b (load-model (root->source model-remote-b: file: "/x") #f)) (ar (dict-ref (dict-ref (git-status-model-state a) data: #{}) repo: #{})) (br (dict-ref (dict-ref (git-status-model-state b) data: #{}) repo: #{}))) (assert-equal #f (git-repository=? ar br)) (when (string=? (git-repository-key ar) (git-repository-key br)) (error "identity collision"))))(test "browser-only root is unavailable without a Git request" (set! REQUESTS '()) (let ((model (load-model (root->source model-browser: file: "/note") #f))) (assert-equal unavailable: (dict-ref (git-status-model-state model) state: #f)) (assert-equal '() REQUESTS)))(test "late discovery reply cannot overwrite a newer generation" (set! HELD '()) (let ((model (make-git-status-model #f))) (git-status-model-load! model (root->source model-held: file: "/old/a") #f) (git-status-model-load! model (root->source model-held: file: "/new/b") #f) ((car HELD)) (assert-equal loading: (dict-ref (git-status-model-state model) state: #f)) ((cadr HELD)) ((caddr HELD)) (assert-equal ready: (dict-ref (git-status-model-state model) state: #f)) (assert-equal 2 (git-status-model-generation model))))(test "snapshot equality is pure and row ids are stable across snapshots" (let* ((repo-a #{ root: model-remote-a: worktree: "/r" common-dir: "/r/.git" }) (repo-b #{ root: model-remote-b: worktree: "/r" common-dir: "/r/.git" }) (snap #{ head-oid: "h" index-id: "i" worktree-id: "w" }) (changed #{ head-oid: "h" index-id: "i2" worktree-id: "w" }) (entry #{ id: "file-1" }) (other-entry #{ id: "file-2" })) (assert-equal #t (git-snapshot=? snap snap)) (assert-equal #f (git-snapshot=? snap changed)) ;; Refreshing repository data must not invalidate selection identity. (assert-equal (git-row-id repo-a snap entry) (git-row-id repo-a changed entry)) ;; Repository provenance and entry identity remain distinguishing inputs. (assert-equal #f (string=? (git-row-id repo-a snap entry) (git-row-id repo-b snap entry))) (assert-equal #f (string=? (git-row-id repo-a snap entry) (git-row-id repo-a snap other-entry)))))(test "malformed discovery becomes an explicit error state" (register-provider! model-malformed: #{ git-request: (lambda (source verb args on-ready on-error) (on-ready #{ version: 1 })) }) (register-root! model-malformed: #{ provider: model-malformed: class: machine: }) (let ((model (load-model (root->source model-malformed: file: "/x") #f))) (assert-equal error: (dict-ref (git-status-model-state model) state: #f))))(test "refresh keeps ready data visible as stale while pending" (let ((model (load-model (root->source model-local: file: "/x") #f))) (git-status-model-refresh! model (root->source model-held: file: "/x") #f) (assert-equal stale: (dict-ref (git-status-model-state model) state: #f)) (assert-equal #t (dict? (dict-ref (git-status-model-state model) data: #f)))))(test "held remote reply exposes loading and accepts client work before release" (set! HELD '()) (let* ((beats '()) (model (make-git-status-model (lambda (state) ;; This listener runs on the client event lane. Recording a ;; heartbeat proves the held node callback did not block it. (set! beats (cons (dict-ref state state: #f) beats)))))) (git-status-model-load! model (root->source model-held: file: "/remote/repo/a") #f) (assert-equal loading: (dict-ref (git-status-model-state model) state: #f)) (assert-equal (list loading:) beats) ;; Closing a view drops its model; a subsequent client action need not wait ;; for this held callback. Release here only to keep the fixture isolated. (assert-equal 1 (length HELD)) ((car HELD)) ((cadr HELD)) (assert-equal ready: (dict-ref (git-status-model-state model) state: #f))))(test "hunk mutation sends structured identity instead of rendered patch text" (set! MUTATION-ARGS #f) (let* ((source (root->source model-remote-a: file: "/x")) (model (load-model source #f)) (identity #{ file-id: "file-1" side: "worktree" old-start: 1 old-lines: 2 new-start: 1 new-lines: 3 context-hash: "context" })) (git-status-model-mutate-hunk! model source "git/stage-hunk" identity #f #f) (assert-equal identity (dict-ref MUTATION-ARGS hunk-id: #f)) (assert-equal #f (dict-ref MUTATION-ARGS patch: #f))))(test "watch event storm marks data stale without starting Git work" (set! HELD '()) (let* ((source (root->source model-held: file: "/remote/repo/a")) (model (make-git-status-model #f))) (vector-set! model 1 #{ state: ready: generation: 0 data: #{ repo: #{} } }) (git-status-model-watch-event! model source #f) (git-status-model-watch-event! model source #f) (git-status-model-watch-event! model source #f) (assert-equal stale: (dict-ref (git-status-model-state model) state: #f)) (assert-equal #t (dict-ref (git-status-model-state model) external-change?: #f)) (assert-equal '() HELD)))(test "mutation-generated watch noise cannot invalidate its response" (set! HELD '()) (let* ((source (root->source model-held: file: "/remote/repo/a")) (model (make-git-status-model #f))) ;; A mutation owns the current generation until its fresh-status response ;; arrives. Watch events caused by its filesystem writes must not start a ;; competing load and advance that generation. (vector-set! model 1 #{ state: ready: generation: 7 mutation: pending: data: #{} }) (vector-set! model 2 7) (git-status-model-watch-event! model source #f) (assert-equal 7 (git-status-model-generation model)) (assert-equal '() HELD)))(test "explicit refresh rejects excess status calls" (set! HELD '()) (let* ((model (load-model (root->source model-local: file: "/x") #f)) (source (root->source model-held: file: "/x"))) (git-status-model-refresh! model source #f) (git-status-model-refresh! model source #f) (assert-equal 1 (length HELD)) ((car HELD)) (git-status-model-refresh! model source #f) (assert-equal 2 (length HELD)) ((cadr HELD)) (assert-equal ready: (dict-ref (git-status-model-state model) state: #f)) (assert-equal 2 (length HELD))))(test "watch during explicit refresh preserves progress without automatic reload" (set! HELD '()) (let* ((model (load-model (root->source model-local: file: "/x") #f)) (source (root->source model-held: file: "/x"))) (git-status-model-refresh! model source #f) ((car HELD)) (git-status-model-watch-event! model source #f) (assert-false (dict-ref (git-status-model-state model) external-change?: #f)) ((cadr HELD)) (assert-equal 2 (length HELD)) (assert-true (dict-ref (git-status-model-state model) external-change?: #f)) (assert-true (dict? (dict-ref (git-status-model-state model) data: #f)))))(test "refresh cannot invalidate a pending mutation" (set! HELD '()) (let* ((model (make-git-status-model #f)) (source (root->source model-held: file: "/x"))) (vector-set! model 1 #{ state: ready: generation: 7 mutation: pending: data: #{} }) (vector-set! model 2 7) (git-status-model-refresh! model source #f) (assert-equal 7 (git-status-model-generation model)) (assert-equal '() HELD)))(test "late error cannot release a newer refresh guard" (let ((errors '())) (register-provider! model-errors: #{ git-request: (lambda (source verb args on-ready on-error) (set! errors (append errors (list on-error)))) }) (register-root! model-errors: #{ provider: model-errors: class: machine: }) (let* ((model (load-model (root->source model-local: file: "/x") #f)) (source (root->source model-errors: file: "/x"))) (git-status-model-load! model source #f) (git-status-model-refresh! model source #f) (assert-equal 2 (length errors)) ((car errors) operation-failed:) (git-status-model-refresh! model source #f) (assert-equal 2 (length errors)) ((cadr errors) operation-failed:) (assert-equal error: (dict-ref (git-status-model-state model) state: #f)))))(test "unavailable refresh releases its guard for recovery" (let ((model (load-model (root->source model-local: file: "/x") #f))) (git-status-model-refresh! model (root->source model-browser: file: "/x") #f) (assert-equal unavailable: (dict-ref (git-status-model-state model) state: #f)) (git-status-model-refresh! model (root->source model-local: file: "/x") #f) (assert-equal ready: (dict-ref (git-status-model-state model) state: #f))))(test "watch activity cannot hide an explicit refresh failure" (let ((fail #f)) (register-provider! model-refresh-error: #{ git-request: (lambda (source verb args on-ready on-error) (set! fail on-error)) }) (register-root! model-refresh-error: #{ provider: model-refresh-error: class: machine: }) (let* ((model (load-model (root->source model-local: file: "/x") #f)) (source (root->source model-refresh-error: file: "/x"))) (git-status-model-refresh! model source #f) (git-status-model-watch-event! model source #f) (fail operation-failed:) (assert-equal error: (dict-ref (git-status-model-state model) state: #f)))))(test "mutations cannot race a refresh or an earlier mutation" (set! HELD '()) (let* ((model (load-model (root->source model-local: file: "/x") #f)) (source (root->source model-held: file: "/x"))) (git-status-model-refresh! model source #f) (git-status-model-mutate! model source "git/stage-file" "file-1" #f #f) (git-status-model-mutate-hunk! model source "git/stage-hunk" #{} #f #f) (assert-equal 1 (length HELD)) ((car HELD)) ((cadr HELD)) (git-status-model-mutate! model source "git/stage-file" "file-1" #f #f) (assert-equal 3 (length HELD)) (git-status-model-mutate! model source "git/stage-file" "file-1" #f #f) (git-status-model-mutate-hunk! model source "git/stage-hunk" #{} #f #f) (assert-equal 3 (length HELD)) ((caddr HELD)) (assert-equal complete: (dict-ref (git-status-model-state model) mutation: #f))))packages/slate/test/git-status-view-test.sgladded
;;; A3 structured read-only Git status view behavior.(import (sigil core) (sigil test) (sigil string) (slate git diff-model) (slate git model) (slate git review) (slate git status-view) (slate mode) (slate modes git-status) (slate option) (slate main preferences) (slate root) (slate view)); Inspect lazy render output without a browser. Production evaluates these; producers only when the memo revision changes.(define (force-render value) (cond ((and (vector? value) (= (vector-length value) 4) (eq? (vector-ref value 0) 'web-client-memo)) (force-render ((vector-ref value 3)))) ((pair? value) (map force-render value)) (else value)))(define (rendered-text value) (format "~s" (force-render value)))(test "Git status renderer is exported" (assert-true (procedure? git-status-render)) (assert-true (procedure? git-status?)))(test "Git layout defaults to Auto and restores only supported choices" (let* ((decl (option-declaration slate/git-layout:)) (restored (preferences-restore-from #{} #{ slate/git-layout: 'split } #f)) (invalid (preferences-restore-from #{} #{ slate/git-layout: 'unknown } #f))) (assert-equal 'auto (option-default decl)) (assert-equal 'split (dict-ref (dict-ref restored state: #{}) slate/git-layout: #f)) (assert-equal 'auto (dict-ref (dict-ref invalid state: #{}) slate/git-layout: #f))))(test "Git status mode registers its constructor" (assert-true (dict? (mode-descriptor 'git-status-mode))))(define repo #{ root: test-git: worktree: "/repo" common-dir: "/repo/.git" })(define snapshot #{ head-oid: "abc" index-id: "idx" worktree-id: "wt" })(define status #{ version: 1 repo: repo snapshot: snapshot branch: #{ head: "main" upstream: "origin/main" ahead: 1 behind: 2 } operation: #f entries: (list #{ id: "conflict" path: "conflict.sgl" kind: "conflict" index-state: "U" worktree-state: "U" } #{ id: "work" path: "work.sgl" kind: "ordinary" index-state: "." worktree-state: "M" } #{ id: "new" path: "new.sgl" kind: "untracked" index-state: "?" worktree-state: "?" } #{ id: "stage" path: "stage.sgl" kind: "ordinary" index-state: "M" worktree-state: "." }) recent-commits: (list #{ oid: "abc" subject: "subject" }) capabilities: #{} })(define (ready-view) (let ((model (make-git-status-model #f))) ;; Feed the public reducer result into the model through its vector state slot; ;; transport/generation behavior is covered by git-status-model-test. (vector-set! model 1 #{ state: ready: generation: 1 data: status }) #{ slate/name: "*git-status:test*" slate/source: #{ root: test-git: provider: test: kind: file: address: "/repo/a.sgl" } slate/git-model: model slate/git-selected: #f slate/git-folded: '() slate/git-row-cache: (make-git-status-row-cache) }))(define (ready-mode-view) (let* ((plain (ready-view)) (ctor (dict-ref (mode-descriptor 'git-status-mode) ctor: #f))) (ctor slate/name: (dict-ref plain slate/name: "") slate/source: (dict-ref plain slate/source: #{}) slate/git-model: (dict-ref plain slate/git-model: #f) slate/git-selected: #f slate/git-folded: '())))(test "registered Git status mode renders structured status markup" (let* ((desc (mode-descriptor 'git-status-mode)) (ctor (dict-ref desc ctor: #f)) (plain (ready-view)) (view (ctor slate/name: (dict-ref plain slate/name: "") slate/source: (dict-ref plain slate/source: #{}) slate/git-model: (dict-ref plain slate/git-model: #f) slate/git-selected: #f slate/git-folded: '()))) (assert-equal 'div (car (view-render view)))))(test "ordinary status hides the inactive operation header" (let ((text (rendered-text (git-status-render (ready-view))))) (assert-false (string-contains? text "Operation"))))(test "status summary and recent commits show abbreviated object ids" (let ((text (rendered-text (git-status-render (ready-view))))) (assert-true (string-contains? text "Latest commit")) (assert-true (string-contains? text "git-commit-oid")) (assert-true (string-contains? text "abc"))))(test "mode string reports stable status counts and refresh activity" (let ((view (ready-mode-view))) (assert-equal "Git · 4 changed · 1 staged · 1 untracked" (view-info view)) (vector-set! (git-status-model view) 1 #{ state: stale: generation: 2 data: status }) (assert-equal "Git · refreshing… · 4 changed · 1 staged · 1 untracked" (view-info view))))(test "a newly staged file renders an unstage action" (let* ((view (ready-view)) (model (git-status-model view)) (data (dict-set status entries: (list #{ id: "added" path: "added.sgl" kind: "ordinary" index-state: "A" worktree-state: "." })))) (vector-set! model 1 #{ state: ready: generation: 2 data: data }) (let ((text (rendered-text (git-status-render view)))) (assert-true (string-contains? text "Unstage added.sgl")) (assert-true (string-contains? text "git-select-unstage:")))))(test "status rows contain every required section and recent commits" (let ((rows (git-status-rows (ready-view)))) (assert-equal 10 (length rows)) (assert-equal "section:conflicts" (dict-ref (car rows) id: #f)) (assert-equal "commit:abc" (dict-ref (list-ref rows 9) id: #f))))(test "a staged plus unstaged file appears independently on both diff sides" (let* ((v (ready-view)) (model (git-status-model v)) (both #{ id: "both" path: "both.sgl" kind: "ordinary" index-state: "M" worktree-state: "M" }) (data (dict-set status entries: (list both)))) (vector-set! model 1 #{ state: ready: generation: 2 data: data }) (let ((rows (filter (lambda (row) (eq? (dict-ref row kind: #f) entry:)) (git-status-rows v)))) (assert-equal 2 (length rows)) (assert-true (any (lambda (row) (eq? (dict-ref row section: #f) staged:)) rows)) (assert-true (any (lambda (row) (eq? (dict-ref row section: #f) unstaged:)) rows)))))(test "stage and unstage pointer controls render through registered command families" (let ((text (call-with-output-string (lambda (port) (write (git-status-render (ready-view)) port))))) (assert-true (string-contains? text "git-select-stage:")) (assert-true (string-contains? text "git-select-unstage:"))))(test "status rows omit every zero-count section" (let* ((v (ready-view)) (model (git-status-model v)) (only-work (dict-set status entries: (list #{ id: "work" path: "work.sgl" kind: "ordinary" index-state: "." worktree-state: "M" }))) (without-commits (dict-set only-work recent-commits: '()))) (vector-set! model 1 #{ state: ready: generation: 2 data: without-commits }) (let ((rows (git-status-rows v))) (assert-equal 2 (length rows)) (assert-equal "section:unstaged" (dict-ref (car rows) id: #f)) (assert-equal #f (any (lambda (row) (= 0 (dict-ref row count: 1))) rows)))))(test "navigation aliases share bounded meaningful-row movement" (let* ((v (ready-view)) (down (git-status-move v 1)) (down2 (git-status-move down 1)) (up (git-status-move down2 -1))) (assert-equal (dict-ref down slate/git-selected: #f) (dict-ref up slate/git-selected: #f))))(test "folding a selected section hides only its member rows" (let* ((v (dict-set (ready-view) slate/git-selected: "section:unstaged")) (folded (git-status-toggle v))) (assert-equal 9 (length (git-status-rows folded))) (assert-equal #t (if (member "section:unstaged" (dict-ref folded slate/git-folded: '())) #t #f))))(test "view names retain root provenance" (assert-equal "*git-status:test-git:/repo/a.sgl*" (git-status-view-name (dict-ref (ready-view) slate/source: #{}))) (assert-equal "*git-status:test-git:/repo/a.sgl*" (git-status-view-name #{ root: "test-git" address: "/repo/a.sgl" })))(test "canonical view names collapse files in one root-qualified repository" (assert-equal (git-status-repository-view-name repo) (git-status-repository-view-name #{ root: test-git: worktree: "/repo" common-dir: "/repo/.git" })))(define (work-row view) (car (filter (lambda (row) (and (eq? (dict-ref row kind: #f) entry:) (string=? (dict-ref (dict-ref row entry: #{}) path: "") "work.sgl"))) (git-status-rows view))))(define (inline-view lines complete?) (let* ((view (ready-view)) (entry (work-row view)) (id (dict-ref entry id: "")) (model (make-git-diff-model (dict-ref view slate/source: #{}) repo snapshot "worktree" "work.sgl" #f))) (vector-set! model 1 #{ state: ready: pages: (list #{ files: (list #{ hunks: lines }) next-cursor: (if complete? #f "next") complete: complete? }) next-cursor: (if complete? #f "next") complete: complete? }) (dict-set view slate/git-inline-models: (list (cons id model)))))(test "navigation reuses rows while section and hunk folds invalidate them" (let* ((v (inline-view (list #{ kind: hunk: text: "@@ -1 +1 @@" } #{ kind: addition: text: "+body" }) #t)) (rows (git-status-rows v)) (revision (git-status-row-revision v)) (moved (git-status-move v 1)) (hunk (find (lambda (row) (eq? (dict-ref row kind: #f) hunk:)) rows)) (folded (dict-set moved slate/git-hunk-folded: (list (dict-ref hunk id: #f))))) (assert-true (eq? rows (git-status-rows moved))) (assert-equal revision (git-status-row-revision moved)) (assert-false (equal? revision (git-status-row-revision folded))) (assert-equal (- (length rows) 1) (length (git-status-rows folded))) (assert-equal rows (git-status-rows v)) (let ((section-rows (git-status-rows (dict-set v slate/git-folded: '("section:unstaged"))))) (assert-true (< (length section-rows) (length rows))))))(test "cached rows revoke hunk authority when the model snapshot changes" (let* ((v (inline-view (list #{ kind: hunk: text: "@@ -1 +1 @@" }) #t)) (model (cdr (car (dict-ref v slate/git-inline-models: '())))) (rows (git-status-rows v)) (hunk (find (lambda (row) (eq? (dict-ref row kind: #f) hunk:)) rows))) (assert-true (dict? (dict-ref hunk hunk-id: #f))) (vector-set! model 5 (dict-set snapshot index-id: "changed")) (let* ((next (git-status-rows v)) (hunk (find (lambda (row) (eq? (dict-ref row kind: #f) hunk:)) next))) (assert-false (eq? rows next)) (assert-false (dict-ref hunk hunk-id: #f)))))(test "expanded status file projects structured hunks and continuation inline" (let* ((view (inline-view (list #{ kind: hunk: text: "@@ -1 +1 @@" old-line: #f new-line: #f } #{ kind: deletion: text: "-old" old-line: 1 new-line: #f } #{ kind: addition: text: "+new" old-line: #f new-line: 1 }) #f)) (rows (git-status-rows view))) (assert-true (any (lambda (row) (eq? (dict-ref row kind: #f) hunk:)) rows)) (assert-true (any (lambda (row) (eq? (dict-ref row kind: #f) addition:)) rows)) (assert-true (any (lambda (row) (eq? (dict-ref row kind: #f) continuation:)) rows))))(test "expanded hunk carries structured mutation identity and pointer action" (let* ((view (inline-view (list #{ kind: hunk: text: "@@ -3,2 +3,4 @@ function" old-line: #f new-line: #f } #{ kind: context: text: " line" old-line: 3 new-line: 3 }) #t)) (hunk (car (filter (lambda (row) (eq? (dict-ref row kind: #f) hunk:)) (git-status-rows view)))) (identity (dict-ref hunk hunk-id: #{})) (rendered (rendered-text (git-status-render (dict-set view slate/git-selected: (dict-ref hunk id: "")))))) (assert-equal "worktree" (dict-ref identity side: "")) (assert-equal 3 (dict-ref identity old-start: 0)) (assert-equal 2 (dict-ref identity old-lines: 0)) (assert-equal 4 (dict-ref identity new-lines: 0)) (assert-true (string? (dict-ref identity context-hash: #f))) (assert-true (string-contains? rendered "git-select-stage:")) (assert-true (string-contains? rendered "(title \"Stage hunk\")")) (assert-false (string-contains? rendered "(title \"Unstage hunk\")"))))(test "inline hunk folding retains header and hides its body" (let* ((view (inline-view (list #{ kind: hunk: text: "@@ -1 +1 @@" old-line: #f new-line: #f } #{ kind: deletion: text: "-old" old-line: 1 new-line: #f } #{ kind: addition: text: "+new" old-line: #f new-line: 1 }) #t)) (hunk (car (filter (lambda (row) (eq? (dict-ref row kind: #f) hunk:)) (git-status-rows view)))) (folded (dict-set view slate/git-hunk-folded: (list (dict-ref hunk id: "")))) (rows (git-status-rows folded))) (assert-true (any (lambda (row) (eq? (dict-ref row kind: #f) hunk:)) rows)) (assert-false (any (lambda (row) (eq? (dict-ref row kind: #f) addition:)) rows))))(test "several inline pages cannot exceed the global mounted-row ceiling" (let* ((lines (map (lambda (n) #{ kind: context: text: " line" old-line: n new-line: n }) (iota 1200 1))) (rows (git-status-rows (inline-view lines #t)))) (assert-true (<= (length rows) 800))))(test "Git header outline and command strip are separate layout regions" (let* ((markup (git-status-render (ready-view))) (regions (filter pair? (cddr markup)))) (assert-equal '(header div nav) (map car regions)) (assert-true (string-contains? (rendered-text (cadr regions)) "git-outline")) (assert-true (string-contains? (rendered-text (caddr regions)) "Git commands"))))(test "layout changes keep one diff control and preserve selection folds and models" (let* ((view (inline-view (list #{ kind: hunk: text: "@@ -1 +1 @@" } #{ kind: addition: text: "+preserved" new-line: 1 }) #t)) (id (dict-ref (work-row view) id: "")) (view (dict-set view slate/git-selected: id)) (before (git-status-rows view)) (models (dict-ref view slate/git-inline-models: '())) (prior (set-option-live-reader! (lambda (key default) 'auto)))) (for-each (lambda (layout) (set-option-live-reader! (lambda (key default) layout)) (let ((text (rendered-text (git-status-render view)))) (assert-true (string-contains? text "git-inline-diff active")) (assert-equal 2 (length (string-split text "(class \"slate-diff-control\")"))) (assert-equal before (git-status-rows view)) (assert-equal id (dict-ref view slate/git-selected: #f)) (assert-equal models (dict-ref view slate/git-inline-models: '())))) '(auto inline split)) (set-option-live-reader! prior)))(test "clean working tree has an explicit empty state" (let* ((view (ready-view)) (data (dict-set status entries: '()))) (vector-set! (git-status-model view) 1 #{ state: ready: data: data }) (assert-true (string-contains? (rendered-text (git-status-render view)) "Working tree clean"))))(test "snapshot-scoped entry IDs do not replace display identity or close a folded section review" (let* ((v (inline-view (list #{ kind: hunk: text: "@@ -1 +1 @@" }) #t)) (id (dict-ref (work-row v) id: #f)) (v (dict-set (dict-set v slate/git-selected: id) slate/git-folded: (list "section:unstaged"))) (data (dict-set status entries: (map (lambda (entry) (dict-set entry id: (string-append "fresh-" (dict-ref entry id: "")))) (dict-ref status entries: '())))) (next (git-review-status v data))) (assert-equal (dict-ref v slate/git-inline-models: '()) (dict-ref next slate/git-inline-models: '())) (assert-equal (list "section:unstaged") (dict-ref next slate/git-folded: '())) (assert-equal id (dict-ref (work-row (dict-set next slate/git-folded: '())) id: #f))))(test "staging a file continues at the next file in its source section" (let* ((v (ready-view)) (work (dict-ref (work-row v) entry: #{})) (other #{ id: "other" path: "other.sgl" kind: "ordinary" index-state: "." worktree-state: "M" }) (before (dict-set status entries: (list work other))) (v (dict-set (dict-set v slate/git-data: before) slate/git-selected: (dict-ref (work-row v) id: #f))) (fresh (dict-set before entries: (list (dict-set (dict-set work index-state: "M") worktree-state: ".") other))) (next (git-review-status v fresh)) (selected (git-status-selected-row next))) (assert-equal "other.sgl" (dict-ref (dict-ref selected entry: #{}) path: #f)) (assert-equal unstaged: (dict-ref selected section: #f))))(test "a pending mutation does not override a later selection" (let* ((v (dict-set (ready-view) slate/git-selected: "section:staged")) (next (git-review-status v (dict-set status entries: (cdr (dict-ref status entries: '())))))) (assert-equal "section:staged" (dict-ref next slate/git-selected: #f))))(test "retained old pages cannot render hunk mutation actions under a fresh snapshot" (let* ((v (inline-view (list #{ kind: hunk: text: "@@ -1 +1 @@" } #{ kind: addition: text: "+retained" }) #t)) (fresh (dict-set status snapshot: #{ head-oid: "abc" index-id: "new-index" worktree-id: "wt" })) (next (git-review-status v fresh)) (hunk (find (lambda (row) (eq? (dict-ref row kind: #f) hunk:)) (git-status-rows next)))) (assert-false (dict-ref hunk hunk-id: #f)) (assert-true (string-contains? (rendered-text (git-status-render next)) "retained")) (assert-false (string-contains? (rendered-text (git-status-render next)) "slate-diff-mutation"))))(test "refresh preserves a shifted hunk fold and selects the nearest remaining hunk" (let* ((first #{ kind: hunk: text: "@@ -1 +1,2 @@" }) (second #{ kind: hunk: text: "@@ -20 +21 @@" }) (v (inline-view (list first #{ kind: addition: text: "+first" } second #{ kind: addition: text: "+second" }) #t)) (id (dict-ref (work-row v) id: #f)) (model (cdr (assoc id (dict-ref v slate/git-inline-models: '())))) (old (git-diff-model-state model)) (hunks (filter (lambda (row) (eq? (dict-ref row kind: #f) hunk:)) (git-status-rows v))) (v (dict-set (dict-set v slate/git-selected: (dict-ref (car hunks) id: #f)) slate/git-hunk-folded: (list (dict-ref (cadr hunks) id: #f))))) (vector-set! model 1 #{ state: ready: pages: (list #{ files: (list #{ hunks: (list #{ kind: hunk: text: "@@ -21 +21 @@" } #{ kind: addition: text: "+second" }) }) }) complete: #t }) (let* ((next (git-review-diff v id old)) (selected (git-status-selected-row next))) (assert-equal "@@ -21 +21 @@" (dict-ref selected text: #f)) (assert-true (dict-ref selected folded?: #f)) (assert-false (any (lambda (row) (eq? (dict-ref row kind: #f) addition:)) (git-status-rows next))))))(test "deferred folded review still projects and renders its retained hunk" (let* ((v (inline-view (list #{ kind: hunk: text: "@@ -1 +1 @@" } #{ kind: addition: text: "+retained" }) #t)) (model (cdr (car (dict-ref v slate/git-inline-models: '())))) (hunk (find (lambda (row) (eq? (dict-ref row kind: #f) hunk:)) (git-status-rows v))) (v (dict-set v slate/git-hunk-folded: (list (dict-ref hunk id: #f))))) (vector-set! model 1 (dict-set (dict-set (git-diff-model-state model) state: deferred:) rebasing: #t)) (let ((rows (git-status-rows v)) (markup (rendered-text (git-status-render v)))) (assert-true (any (lambda (row) (eq? (dict-ref row kind: #f) hunk:)) rows)) (assert-true (string-contains? markup "Refresh this review")) (assert-true (string-contains? markup "@@ -1 +1 @@")) (assert-false (string-contains? markup "+retained")))))(test "selection follows the same surviving hunk when earlier edits shift its header" (let* ((v (inline-view (list #{ kind: hunk: text: "@@ -1 +1,2 @@" } #{ kind: addition: text: "+first" } #{ kind: hunk: text: "@@ -20 +21 @@" } #{ kind: addition: text: "+second" } #{ kind: hunk: text: "@@ -40 +41 @@" } #{ kind: addition: text: "+third" }) #t)) (id (dict-ref (work-row v) id: #f)) (model (cdr (assoc id (dict-ref v slate/git-inline-models: '())))) (old (git-diff-model-state model)) (hunks (filter (lambda (row) (eq? (dict-ref row kind: #f) hunk:)) (git-status-rows v))) (v (dict-set v slate/git-selected: (dict-ref (cadr hunks) id: #f)))) (vector-set! model 1 #{ state: ready: pages: (list #{ files: (list #{ hunks: (list #{ kind: hunk: text: "@@ -21 +21 @@" } #{ kind: addition: text: "+second" } #{ kind: hunk: text: "@@ -41 +41 @@" } #{ kind: addition: text: "+third" }) }) }) complete: #t }) (assert-equal "@@ -21 +21 @@" (dict-ref (git-status-selected-row (git-review-diff v id old)) text: #f))))packages/slate/test/list-test.sglmodified
(test "list-last is the shared last-element operation" (assert-equal (list-last '(first middle last)) 'last))(test "drop-last is the shared all-but-last operation" (assert-equal (drop-last '(first middle last)) '(first middle)))(test-summary)packages/slate/test/local-root-identity-test.sgladded
(import (sigil core) (sigil string) (sigil test) (sigil irc message) (slate provider) (slate root) (slate providers enclave-node))(test-group "registered root locality" (test "opaque node identity reclassifies the canonical root onto loopback" (register-root! local: #{ provider: local: label: "This computer" class: local: local?: #t home: "/home/me" }) (register-root! workstation: #{ provider: node: host: "slate-node-opaque-7" label: "workstation" class: machine: status: online: home: "/home/me" }) (enclave-node-set-local-identity! "slate-node-opaque-7") (let ((canonical (root-ref workstation:)) (alias (root-ref local:))) (assert-equal local: (dict-ref canonical provider: #f)) (assert-true (dict-ref canonical local?: #f)) (assert-true (dict-ref alias hidden?: #f)) (assert-true (member workstation: (root-visible-names))) (assert-false (member local: (root-visible-names))) (assert-true (string-contains? (root-annotation workstation:) "local"))) (unregister-root! workstation:) (unregister-root! local:)) (test "display-label equality alone never marks a root local" (register-root! impostor: #{ provider: node: host: "different-opaque-id" label: "workstation" class: machine: status: online: }) (enclave-node-set-local-identity! "slate-node-opaque-7") (assert-equal node: (dict-ref (root-ref impostor:) provider: #f)) (assert-false (dict-ref (root-ref impostor:) local?: #f)) (unregister-root! impostor:)) (test "loopback identity publishes the canonical root before discovery" (register-root! local: #{ provider: local: label: "This computer" class: local: local?: #t home: "/home/me" }) (assert-equal earlybox: (enclave-node-register-local-root! "slate-node-early" "earlybox" "/home/me")) (let ((root (root-ref earlybox:))) (assert-equal local: (dict-ref root provider: #f)) (assert-equal "slate-node-early" (dict-ref root host: #f)) (assert-equal "/home/me" (dict-ref root home: #f)) (assert-true (dict-ref root local?: #f)) (assert-false (member local: (root-visible-names)))) (unregister-root! earlybox:) (unregister-root! local:)) (test "authoritative discovery preserves the loopback classification" (register-root! local: #{ provider: local: label: "This computer" class: local: local?: #t home: "/home/me" }) (enclave-node-register-local-root! "slate-node-aa561ddb01b9" "zerocool" "/home/me") ;; Even a contradictory catalog row cannot downgrade a fact attested by ;; the authenticated loopback node. This is not label-based locality: the ;; canonical root already carries the remembered opaque local identity. (enclave-node-on-message! (parse-irc-message ":server ENCLAVE-NODE zerocool stale-catalog-identity OFFLINE")) (let ((root (root-ref zerocool:))) (assert-equal local: (dict-ref root provider: #f)) (assert-true (dict-ref root local?: #f)) (assert-equal online: (dict-ref root status: #f)) (assert-equal "slate-node-aa561ddb01b9" (dict-ref root host: #f)) (assert-equal "/home/me" (dict-ref root home: #f))) (unregister-root! zerocool:) (unregister-root! local:)) (test "durable pairing retains a cached remote root without account discovery" ;; This is the cold account-lane path: sys/info came from the authenticated ;; loopback node, while no ENCLAVE-NODE/ENCLAVE-NODES frame arrived. (enclave-node-reset!) (register-root! zerocool: #{ provider: node: host: "slate-node-peer-01" label: "zerocool" class: machine: status: unknown: home: "/home/daviwil" planes: (list resource: session:) }) (enclave-node-register-pairings! (list #{ transport-nick: "slate-node-peer-01" revoked: #f offline-ready?: #t })) (let ((root (root-ref zerocool:))) (assert-true root) (assert-equal "zerocool" (dict-ref root label: #f)) (assert-equal "slate-node-peer-01" (dict-ref root host: #f)) (assert-equal "/home/daviwil" (dict-ref root home: #f))) ;; A later authoritative local ledger without the pairing removes it; an ;; old async fs/home result is generation-gated and cannot resurrect it. (enclave-node-register-pairings! '()) (assert-false (root-ref zerocool:)) (enclave-node-reset!)) )packages/slate/test/navigation-command-test.sglmodified
(assert-equal (command-module (command-resolve event)) '(slate commands navigation))) (list "view-switch-recent" "show-messages" "scratchpad" "welcome" "slate-performance-start" "slate-performance-save" "close-view" "clear" "reload-init" "switchspace:other" "switch:b" "reveal-path:0:local:/tmp/a")))packages/slate/test/node-companion-restart-live.sgladded
;;; Companion restart -> generation-scoped carrier loss -> durable reattach.;;; Orchestrated only by scripts/test-node-direct-live.(import (sigil core) (sigil async) (sigil io) (sigil math) (sigil process) (sigil string) (sigil test) (sigil time) (slate enclave-account session) (slate provider) (slate providers enclave-node) (slate providers node))(define endpoint (getenv "NODE_DIRECT_ENCLAVE_WS"))(define account (getenv "NODE_DIRECT_ACCOUNT"))(define credential (getenv "NODE_DIRECT_CREDENTIAL"))(define companion (getenv "NODE_DIRECT_COMPANION_WS"))(define companion-token (getenv "NODE_DIRECT_COMPANION_TOKEN"))(define target (getenv "NODE_DIRECT_TARGET"))(define marker (getenv "NODE_DIRECT_MARKER"))(define restart-flag (getenv "NODE_COMPANION_RESTART_FLAG"))(define sentinel (getenv "NODE_DIRECT_SESSION_SENTINEL"))(test "a companion restart retires its generation and reattaches the original PTY" (with-async (define account-ready #f) (define account-failure #f) (define output "") (define terminal-error #f) (define lost 0) (define reattached 0) (define reattach-transport #f) (define running #t) (enclave-node-reset!) (node-reset!) (enclave-node-register!) (node-set-token! companion-token) (node-connect! companion) (account-session-on-message! enclave-node-on-message!) (account-session-start-saved! #{ version: 1 url: endpoint nick: account credential: credential } (lambda () (set! account-ready #t)) (lambda (reason) (set! account-failure reason))) (go (let loop () (account-session-poll!) (when running (sleep 0.001) (loop)))) (define (pump-until label predicate limit) (let loop ((n 0)) (enclave-node-tick! (exact (floor (* 1000 (current-second))))) (node-poll!) (enclave-node-poll!) (cond ((predicate) #t) ((>= n limit) (error (string-append "companion restart timeout: " label) account-failure terminal-error output lost reattached (enclave-node-reattach-count) (enclave-node-reattach-failures) (enclave-node-reattach-debug) (node-phase))) (else (sleep 0.01) (loop (+ n 1)))))) (pump-until "account and companion authenticate" (lambda () (and account-ready (node-ready?))) 4000) (let* ((started (string-append marker "-companion-started")) (buffered (string-append marker "-companion-buffered")) (handle (source-session-open #{ provider: node: kind: pty: host: target argv: (list "sh" "-c" (string-append "if [ -e '" sentinel "' ]; then printf 'REPLACEMENT-PTY\\n'; exit 9; fi; : > '" sentinel "'; printf '" started "\\n'; (sleep 5; printf '" buffered "\\n') & while read line; do printf 'after:%s\\n' \"$line\"; done")) cols: 80 rows: 24 } (lambda (event) (let ((bytes (dict-ref event bytes: #f))) (when bytes (set! output (string-append output bytes)))) (when (dict-ref event disconnected: #f) (set! lost (+ lost 1))) (when (dict-ref event reattached: #f) (set! reattached (+ reattached 1)) (set! reattach-transport (dict-ref event transport: #f))) (when (dict-ref event error: #f) (set! terminal-error (dict-ref event error: #f)))) #{ window: 4096 }))) (pump-until "PTY starts on the original companion generation" (lambda () (string-contains? output started)) 4000) ;; This phase deliberately exercises the implemented durable owner: a ;; relay session lives in the target holder, outside the caller companion ;; being restarted. Direct sessions still live in the companion-local hub ;; and belong to the later hub-independence design phase. (assert-equal 'relay (enclave-node-carrier-transport target)) (call-with-output-file restart-flag (lambda (port) (display "restart\n" port))) (pump-until "old companion generation reports one loss" (lambda () (> lost 0)) 6000) (assert-equal 1 lost) (pump-until "replacement companion authenticates" (lambda () (node-ready?)) 6000) (pump-until "durable PTY reattaches over a fresh carrier" (lambda () (> reattached 0)) 6000) (assert-equal 1 reattached) (assert-equal 'relay reattach-transport) (pump-until "output buffered during companion restart is replayed" (lambda () (string-contains? output buffered)) 4000) (when (string-contains? output "REPLACEMENT-PTY") (error "companion restart opened a replacement PTY")) (session-write handle "companion-restored\n") (pump-until "input reaches the original PTY after restart" (lambda () (string-contains? output "after:companion-restored")) 4000) (assert-equal reattach-transport (enclave-node-carrier-transport target)) (assert-equal 0 (enclave-node-reattach-count)) (session-close handle)) (set! running #f) (account-session-send-command! "ENCLAVE-NODES") (sleep 0.01) (enclave-node-reset!) (node-disconnect!) (account-session-close!)))packages/slate/test/node-direct-live.sgladded
;;; Real caller companion -> Enclave rendezvous -> target node live gate.;;; Orchestrated by scripts/test-node-direct-live.(import (sigil core) (sigil async) (sigil io) (sigil path) (sigil process) (sigil string) (sigil test) (sigil time) (slate enclave-account session) (slate provider) (slate providers enclave-node) (slate providers node))(define endpoint (getenv "NODE_DIRECT_ENCLAVE_WS"))(define account (getenv "NODE_DIRECT_ACCOUNT"))(define credential (getenv "NODE_DIRECT_CREDENTIAL"))(define companion (getenv "NODE_DIRECT_COMPANION_WS"))(define companion-token (getenv "NODE_DIRECT_COMPANION_TOKEN"))(define target (getenv "NODE_DIRECT_TARGET"))(define path (getenv "NODE_DIRECT_READABLE"))(define writable (getenv "NODE_DIRECT_WRITABLE"))(define marker (getenv "NODE_DIRECT_MARKER"))(define expected (if (string=? (getenv "NODE_DIRECT_EXPECT_TRANSPORT") "direct") 'direct 'relay))(test "real node carrier selects the expected transport" (with-async (define account-ready #f) (define account-failure #f) (define result #f) (define failure #f) (define write-failure #f) (define watch-event #f) (define mutation-result #f) (define mutation-failure #f) (define listing-result #f) (define transfer-progress '()) (define terminal-output "") (define terminal-error #f) (define running #t) (enclave-node-reset!) (node-reset!) (enclave-node-register!) (node-set-token! companion-token) (node-connect! companion) (account-session-on-message! enclave-node-on-message!) (account-session-start-saved! #{ version: 1 url: endpoint nick: account credential: credential } (lambda () (set! account-ready #t)) (lambda (reason) (set! account-failure reason))) (go (let loop () (account-session-poll!) ;; The account pump is deliberately nowait. Yield explicitly so this ;; native live probe models the browser metronome instead of monopolizing ;; the scheduler once the socket has no work ready. (when running (sleep 0.001) (loop)))) (define (pump-until label predicate limit) (let loop ((n 0)) (node-poll!) (enclave-node-poll!) (cond ((predicate) #t) ((>= n limit) (error (string-append "node direct live timeout: " label) account-failure failure terminal-error terminal-output (node-phase))) (else (sleep 0.01) (loop (+ n 1)))))) (define (pump-for turns) (let loop ((n 0)) (when (< n turns) (node-poll!) (enclave-node-poll!) (sleep 0.01) (loop (+ n 1))))) (pump-until "account and caller companion authenticate" (lambda () (and account-ready (node-ready?))) 4000) (let* ((source #{ provider: node: kind: file: host: target address: path }) (op (provider-async-op source))) (op source (lambda (value) (set! result value)) (lambda (reason) (set! failure reason)))) (pump-until "remote file request settles" (lambda () (or result failure)) 4000) (when failure (error "remote file request failed" failure)) (let ((text (if (bytevector? result) (utf8->string result) result))) (unless (and (string? text) (string-contains? text marker)) (error "remote file returned wrong content" result))) (assert-equal expected (enclave-node-carrier-transport target)) ;; Session traffic must use the same selected carrier as resources. Start ;; with deliberately insufficient credit so the marker cannot arrive, ;; then replenish the window and exercise the bidirectional PTY controls. (let* ((session-marker (string-append marker "-session-" (symbol->string expected))) (prefix "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789--") (handle (source-session-open #{ provider: node: kind: pty: host: target argv: (list "sh" "-c" (string-append "trap 'stty size' WINCH; printf '" prefix session-marker "\\n'; while read line; do printf 'input:%s\\n' \"$line\"; stty size; done")) cols: 80 rows: 24 } (lambda (event) (let ((bytes (dict-ref event bytes: #f)) (err (dict-ref event error: #f))) (when bytes (set! terminal-output (string-append terminal-output bytes))) (when err (set! terminal-error err)))) #{ window: 32 }))) (pump-until "PTY exhausts its initial consumer credit" (lambda () (= (string-length terminal-output) 32)) 4000) (when (string-contains? terminal-output session-marker) (error "PTY marker escaped the consumer credit window")) (session-credit handle 4096) (pump-until "PTY resumes after consumer credit" (lambda () (or terminal-error (string-contains? terminal-output session-marker))) 4000) (when terminal-error (error "remote PTY failed" terminal-error)) (session-write handle "carrier-input\n") (pump-until "PTY input crosses the selected carrier" (lambda () (string-contains? terminal-output "input:carrier-input")) 4000) (session-resize handle 101 33) (session-write handle "size-probe\n") (pump-until "PTY resize crosses the selected carrier" (lambda () (string-contains? terminal-output "33 101")) 4000) (unless (session-detach handle) (error "provider refused session detach")) (unless (session-attach handle) (error "provider refused session reattach")) (session-write handle "reattached-input\n") (pump-until "reattached PTY uses a fresh ticket on the selected carrier" (lambda () (string-contains? terminal-output "input:reattached-input")) 4000) (assert-equal expected (enclave-node-carrier-transport target)) (session-close handle)) ;; The provider persists through the deferred 0x10 bulk lane. Subscribe ;; first, then use that same write to prove the relay/direct watch event and ;; finally unwatch through the returned provider thunk. (enclave-node-on-persist-error! (lambda (_path reason) (set! write-failure reason))) (enclave-node-on-transfer-progress! (lambda (progress) (set! transfer-progress (cons progress transfer-progress)))) (let* ((write-source #{ provider: node: kind: file: host: target address: writable }) (watch-source #{ provider: node: kind: dir: host: target address: (path-dirname writable) }) (unsubscribe (source-subscribe watch-source (lambda (event) (set! watch-event event)))) (written (string-append marker "-written-" (symbol->string expected))) (large-path (path-join (path-dirname writable) "large-transfer.bin")) (large-source #{ provider: node: kind: file: host: target address: large-path }) ;; 5 MiB plus a non-repeating tail. This is deliberately above the ;; renderer's 3 MiB switch and the unary frame's 4 MiB ceiling, so a ;; passing round trip necessarily exercised the credited multi-frame ;; Resource lane. (large-value (string-append (string-repeat "0123456789abcdef" 327680) marker "-large-tail")) (dir-a (path-join (path-dirname writable) "mutation-a")) (dir-b (path-join (path-dirname writable) "mutation-b")) (dir-a-source #{ provider: node: kind: dir: host: target address: dir-a }) (dir-b-source #{ provider: node: kind: dir: host: target address: dir-b })) (define (mutate label start) (set! mutation-result #f) (set! mutation-failure #f) (start (lambda (value) (set! mutation-result value)) (lambda (reason) (set! mutation-failure reason))) (pump-until label (lambda () (or mutation-failure mutation-result)) 4000) (when mutation-failure (error label mutation-failure)) (assert-equal expected (enclave-node-carrier-transport target))) (define (assert-directory-entry label name expected?) (set! listing-result #f) (set! mutation-failure #f) ((provider-async-op watch-source) watch-source (lambda (value) (set! listing-result value)) (lambda (reason) (set! mutation-failure reason))) (pump-until label (lambda () (or listing-result mutation-failure)) 4000) (when mutation-failure (error label mutation-failure)) (assert-equal expected? (and (find (lambda (entry) (equal? (dict-ref entry name: #f) name)) listing-result) #t))) (define (direction-progress direction) (reverse (filter (lambda (entry) (and (eq? (dict-ref entry direction: #f) direction) (equal? (dict-ref entry host: #f) target))) transfer-progress))) (define (assert-transfer-progress label direction expected-total) (let ((entries (direction-progress direction))) (unless (pair? entries) (error (string-append label ": no progress events"))) (let loop ((xs entries) (previous 0)) (when (pair? xs) (let* ((entry (car xs)) (completed (dict-ref entry completed: -1)) (total (dict-ref entry total: -1))) (unless (and (integer? completed) (>= completed previous) (<= completed expected-total) (= total expected-total)) (error (string-append label ": invalid progress") entry)) (loop (cdr xs) completed)))) (let ((last (car (reverse entries)))) (assert-equal 'complete (dict-ref last state: #f)) (assert-equal expected-total (dict-ref last completed: -1)) (unless (<= (dict-ref last peak-outstanding: 4294967295) 262144) (error (string-append label ": credit high-water exceeded") last)) (unless (<= (dict-ref last peak-frame-bytes: 4294967295) 65536) (error (string-append label ": frame high-water exceeded") last))))) ;; Wait for fs/watch's ok before causing the mutation. (pump-until "watch request settles" (lambda () (= (enclave-node-pending-count) 0)) 4000) ;; Let the watcher establish its baseline before the mutation. The watch ;; implementation is polling and intentionally does not report the ;; directory's pre-existing contents as changes. (sleep 1.1) (source-persist write-source written) (pump-until "bulk write and watch event settle" (lambda () (or write-failure watch-event)) 4000) (when write-failure (error "remote bulk write failed" write-failure)) (unless watch-event (error "remote watch produced no event")) (set! result #f) (set! failure #f) ((provider-async-op write-source) write-source (lambda (value) (set! result value)) (lambda (reason) (set! failure reason))) (pump-until "written file reads back" (lambda () (or result failure)) 4000) (when failure (error "remote written file read failed" failure)) (unless (equal? result written) (error "remote bulk write returned wrong content" result)) ;; Directory mutations use the same provider/correlation contract as the ;; file operations. The platform watcher reports file changes, not ;; directory inode creation, so verify each durable state with fs/stat. (mutate "remote mkdir settles" (lambda (ok err) (source-mkdir dir-a-source ok err))) (assert-directory-entry "created directory is visible" "mutation-a" #t) (mutate "remote rename settles" (lambda (ok err) (source-rename dir-a-source dir-b-source ok err))) (assert-directory-entry "renamed source is absent" "mutation-a" #f) (assert-directory-entry "renamed directory is visible" "mutation-b" #t) (mutate "remote delete settles" (lambda (ok err) (source-delete dir-b-source ok err))) (assert-directory-entry "deleted directory is absent" "mutation-b" #f) ;; Persist and read back above the unary ceiling over the same selected ;; carrier. Exact equality verifies length, ordering, and every byte; ;; progress verifies acknowledged completion rather than queued bytes. (set! transfer-progress '()) (set! write-failure #f) (set! watch-event #f) (source-persist large-source large-value) (pump-until "large bulk write and watch event settle" (lambda () (or write-failure (and (= (enclave-node-pending-count) 0) watch-event))) 8000) (when write-failure (error "remote large bulk write failed" write-failure)) (assert-equal 0 (enclave-node-transfer-count)) (assert-transfer-progress "large upload" 'upload (string-length large-value)) (set! transfer-progress '()) (set! result #f) (set! failure #f) ((provider-async-op large-source) large-source (lambda (value) (set! result value)) (lambda (reason) (set! failure reason))) (pump-until "large file reads back" (lambda () (or result failure)) 8000) (when failure (error "remote large file read failed" failure)) (unless (and (= (string-length result) (string-length large-value)) (equal? result large-value)) (error "remote large bulk round-trip mismatch" (string-length result) (string-length large-value))) (assert-equal 0 (enclave-node-transfer-count)) (assert-transfer-progress "large download" 'download (string-length large-value)) (assert-equal expected (enclave-node-carrier-transport target)) (unsubscribe) (pump-until "unwatch request settles" (lambda () (= (enclave-node-pending-count) 0)) 4000) ;; Mutate after the unwatch acknowledgement and run longer than one watch ;; poll interval. A late event would prove the listener survived unwatch. (set! watch-event #f) (set! write-failure #f) (source-persist write-source (string-append written "-after-unwatch")) (pump-until "post-unwatch write settles" (lambda () (or write-failure (= (enclave-node-pending-count) 0))) 4000) (when write-failure (error "post-unwatch write failed" write-failure)) (pump-for 130) (when watch-event (error "watch emitted after explicit unwatch" watch-event))) (set! running #f) ;; Native irc-ws-receive parks until one frame arrives. Wake the account ;; worker with a harmless request so it can observe RUNNING=#f and retire ;; before with-async waits for its children. (account-session-send-command! "ENCLAVE-NODES") (sleep 0.01) (enclave-node-reset!) (node-disconnect!) (account-session-close!)))packages/slate/test/node-direct-reattach-driver-test.mjsadded
import assert from 'node:assert/strict';import fs from 'node:fs';const main = fs.readFileSync( new URL('../src/slate/main.sgl', import.meta.url), 'utf8');const start = main.indexOf('(define (slate-node-tick . rest)');const end = main.indexOf('(define (slate-node-active?)', start);assert.notEqual(start, -1, 'slate-node-tick must exist');assert.notEqual(end, -1, 'slate-node-tick boundary must exist');const tick = main.slice(start, end);assert.match( tick, /\(when \(and \(number\? epoch\)[\s\S]*?\(not \(or \(enclave-active\?\) \(account-session-active\?\)\)\)\)[\s\S]*?\(enclave-node-tick! epoch\)\)/, 'the local-node metronome must drive remote-session recovery when the Enclave metronome is inactive',);assert.ok( tick.indexOf('(node-poll!)') < tick.indexOf('(enclave-node-tick! epoch)'), 'the replacement loopback generation must be polled before issuing a direct reattach',);assert.match( tick, /\(when \(terminal-take-visual-damage!\) \(slate-refresh\)\)/, 'an asynchronous attach acknowledgement must repaint the terminal ribbon and dimmer',);console.log('ok - direct-only retained terminals recover and repaint');packages/slate/test/node-multi-root-live.sgladded
;;; Two independently enrolled roots over concurrent direct and relay carriers.;;; Orchestrated only by scripts/test-node-direct-live.(import (sigil core) (sigil async) (sigil io) (sigil math) (sigil process) (sigil string) (sigil test) (sigil time) (slate enclave-account session) (slate provider) (slate providers enclave-node) (slate providers node))(define endpoint (getenv "NODE_DIRECT_ENCLAVE_WS"))(define account (getenv "NODE_DIRECT_ACCOUNT"))(define credential (getenv "NODE_DIRECT_CREDENTIAL"))(define companion (getenv "NODE_DIRECT_COMPANION_WS"))(define companion-token (getenv "NODE_DIRECT_COMPANION_TOKEN"))(define target-a (getenv "NODE_MULTI_ROOT_A"))(define target-b (getenv "NODE_MULTI_ROOT_B"))(define marker (getenv "NODE_DIRECT_MARKER"))(test "concurrent mixed-transport roots keep independent PTYs" (with-async (define account-ready #f) (define account-failure #f) (define output-a "") (define output-b "") (define lost-a 0) (define lost-b 0) (define errors-a '()) (define errors-b '()) (define running #t) (enclave-node-reset!) (node-reset!) (enclave-node-register!) (node-set-token! companion-token) (node-connect! companion) (account-session-on-message! enclave-node-on-message!) (account-session-start-saved! #{ version: 1 url: endpoint nick: account credential: credential } (lambda () (set! account-ready #t)) (lambda (reason) (set! account-failure reason))) (go (let loop () (account-session-poll!) (when running (sleep 0.001) (loop)))) (define (pump-until label predicate limit) (let loop ((n 0)) (enclave-node-tick! (exact (floor (* 1000 (current-second))))) (node-poll!) (enclave-node-poll!) (cond ((predicate) #t) ((>= n limit) (error (string-append "multi-root live timeout: " label) account-failure output-a output-b lost-a lost-b errors-a errors-b (enclave-node-pending-count) (enclave-node-stream-count))) (else (sleep 0.01) (loop (+ n 1)))))) (pump-until "account and companion authenticate" (lambda () (and account-ready (node-ready?))) 4000) (let* ((started-a (string-append marker "-a-started")) (started-b (string-append marker "-b-started")) (handle-a (source-session-open #{ provider: node: kind: pty: host: target-a argv: (list "sh" "-c" (string-append "printf '" started-a "\\n'; while read line; do printf 'a:%s\\n' \"$line\"; done")) cols: 80 rows: 24 } (lambda (event) (let ((bytes (dict-ref event bytes: #f))) (when bytes (set! output-a (string-append output-a bytes)))) (when (dict-ref event disconnected: #f) (set! lost-a (+ lost-a 1))) (when (dict-ref event error: #f) (set! errors-a (cons (dict-ref event error: #f) errors-a)))) #{ window: 4096 })) (handle-b (source-session-open #{ provider: node: kind: pty: host: target-b argv: (list "sh" "-c" (string-append "printf '" started-b "\\n'; while read line; do printf 'b:%s\\n' \"$line\"; done")) cols: 80 rows: 24 } (lambda (event) (let ((bytes (dict-ref event bytes: #f))) (when bytes (set! output-b (string-append output-b bytes)))) (when (dict-ref event disconnected: #f) (set! lost-b (+ lost-b 1))) (when (dict-ref event error: #f) (set! errors-b (cons (dict-ref event error: #f) errors-b)))) #{ window: 4096 }))) (pump-until "both PTYs start" (lambda () (and (string-contains? output-a started-a) (string-contains? output-b started-b))) 8000) (assert-equal 'direct (enclave-node-carrier-transport target-a)) (assert-equal 'relay (enclave-node-carrier-transport target-b)) (assert-equal 2 (enclave-node-stream-count)) (session-write handle-a "first\n") (session-write handle-b "first\n") (pump-until "both PTYs receive independently correlated input" (lambda () (and (string-contains? output-a "a:first") (string-contains? output-b "b:first"))) 4000) (session-close handle-a) (assert-equal 1 (enclave-node-stream-count)) (session-write handle-b "after-a-close\n") (pump-until "root B survives root A close" (lambda () (string-contains? output-b "b:after-a-close")) 4000) (assert-equal 0 lost-a) (assert-equal 0 lost-b) (assert-equal '() errors-a) (assert-equal '() errors-b) (assert-equal 'relay (enclave-node-carrier-transport target-b)) (assert-equal 0 (enclave-node-pending-count)) (session-close handle-b) (assert-equal 0 (enclave-node-stream-count))) (set! running #f) (account-session-send-command! "ENCLAVE-NODES") (sleep 0.01) (enclave-node-reset!) (node-disconnect!) (account-session-close!)))packages/slate/test/node-network-transition-live.sgladded
;;; Established direct carrier -> explicit durable relay reattach live gate.;;; Orchestrated only by scripts/test-node-direct-live.(import (sigil core) (sigil async) (sigil io) (sigil math) (sigil process) (sigil string) (sigil test) (sigil time) (slate enclave-account session) (slate provider) (slate providers enclave-node) (slate providers node))(define endpoint (getenv "NODE_DIRECT_ENCLAVE_WS"))(define account (getenv "NODE_DIRECT_ACCOUNT"))(define credential (getenv "NODE_DIRECT_CREDENTIAL"))(define companion (getenv "NODE_DIRECT_COMPANION_WS"))(define companion-token (getenv "NODE_DIRECT_COMPANION_TOKEN"))(define target (getenv "NODE_DIRECT_TARGET"))(define marker (getenv "NODE_DIRECT_MARKER"))(define kill-flag (getenv "NODE_DIRECT_KILL_FLAG"))(define sentinel (getenv "NODE_DIRECT_SESSION_SENTINEL"))(test "a direct-to-relay transition preserves the durable PTY explicitly" (with-async (define account-ready #f) (define account-failure #f) (define output "") (define terminal-error #f) (define lost 0) (define reattached 0) (define reattach-transport #f) (define running #t) (enclave-node-reset!) (node-reset!) (enclave-node-register!) (node-set-token! companion-token) (node-connect! companion) (account-session-on-message! enclave-node-on-message!) (account-session-start-saved! #{ version: 1 url: endpoint nick: account credential: credential } (lambda () (set! account-ready #t)) (lambda (reason) (set! account-failure reason))) (go (let loop () (account-session-poll!) (when running (sleep 0.001) (loop)))) (define (pump-until label predicate limit) (let loop ((n 0)) ;; Match (slate main)'s production metronome: the pump discovers the ;; closed carrier, while the tick schedules and issues reattachment. (enclave-node-tick! (exact (floor (* 1000 (current-second))))) (node-poll!) (enclave-node-poll!) (cond ((predicate) #t) ((>= n limit) (error (string-append "node loss live timeout: " label) account-failure terminal-error output lost reattached (enclave-node-reattach-count))) (else (sleep 0.01) (loop (+ n 1)))))) (pump-until "account and direct companion authenticate" (lambda () (and account-ready (node-ready?))) 4000) (let* ((started (string-append marker "-started")) (buffered (string-append marker "-buffered-during-loss")) (handle (source-session-open #{ provider: node: kind: pty: host: target argv: (list "sh" "-c" (string-append "if [ -e '" sentinel "' ]; then printf 'REPLACEMENT-PTY\\n'; exit 9; fi; : > '" sentinel "'; printf '" started "\\n'; (sleep 14; printf '" buffered "\\n') & while read line; do printf 'after:%s\\n' \"$line\"; done")) cols: 80 rows: 24 } (lambda (event) (let ((bytes (dict-ref event bytes: #f))) (when bytes (set! output (string-append output bytes)))) (when (dict-ref event disconnected: #f) (set! lost (+ lost 1))) (when (dict-ref event reattached: #f) (set! reattached (+ reattached 1)) (set! reattach-transport (dict-ref event transport: #f))) (when (dict-ref event error: #f) (set! terminal-error (dict-ref event error: #f)))) #{ window: 4096 }))) (pump-until "direct PTY starts" (lambda () (string-contains? output started)) 4000) (assert-equal 'direct (enclave-node-carrier-transport target)) (call-with-output-file kill-flag (lambda (port) (display "kill\n" port))) (pump-until "direct carrier loss reaches the session" (lambda () (> lost 0)) 6000) (assert-equal 1 lost) (pump-until "the durable session reattaches" (lambda () (> reattached 0)) 5000) (assert-equal 1 reattached) (assert-equal 'relay reattach-transport) (assert-equal 'relay (enclave-node-carrier-transport target)) (assert-equal 1 (enclave-node-stream-count)) (when terminal-error (error "transport transition terminated the durable PTY" terminal-error)) (pump-until "output buffered during loss is replayed" (lambda () (string-contains? output buffered)) 4000) (when (string-contains? output "REPLACEMENT-PTY") (error "reattach opened a replacement PTY")) (session-write handle "relay-restored\n") (pump-until "input reaches the original PTY after relay reattach" (lambda () (string-contains? output "after:relay-restored")) 4000) (assert-equal 1 lost) (assert-equal 1 reattached) (assert-equal 0 (enclave-node-reattach-count)) (assert-equal 0 (enclave-node-pending-count)) (when terminal-error (error "transport transition terminated the durable PTY" terminal-error)) (session-close handle) (assert-equal 0 (enclave-node-stream-count))) (set! running #f) (account-session-send-command! "ENCLAVE-NODES") (sleep 0.01) (enclave-node-reset!) (account-session-close!)))packages/slate/test/node-paired-offline-invalidate-live.sgladded
;;; Active paired-carrier invalidation gate. Orchestrated by;;; scripts/test-node-direct-live while Enclave is stopped.(import (sigil core) (sigil async) (sigil io) (sigil process) (sigil string) (sigil test) (sigil time) (slate provider) (slate providers enclave-node) (slate providers node))(define companion (getenv "NODE_DIRECT_COMPANION_WS"))(define companion-token (getenv "NODE_DIRECT_COMPANION_TOKEN"))(define target (getenv "NODE_DIRECT_TARGET"))(define writable (getenv "NODE_DIRECT_WRITABLE"))(define marker (getenv "NODE_DIRECT_MARKER"))(define armed-flag (getenv "NODE_PAIRING_INVALIDATE_FLAG"))(test "live pairing invalidation closes active work exactly once" (with-async (define output "") (define session-error #f) (define lost 0) (define persist-failure #f) (define progress '()) (enclave-node-reset!) (node-reset!) (enclave-node-register!) (node-set-token! companion-token) (node-connect! companion) (define (pump-until label predicate limit) (let loop ((n 0)) (node-poll!) (enclave-node-poll!) (cond ((predicate) #t) ((>= n limit) (error (string-append "paired invalidation timeout: " label) persist-failure session-error lost (enclave-node-transfer-count) (enclave-node-carrier-transport target))) (else (sleep 0.01) (loop (+ n 1)))))) (enclave-node-on-persist-error! (lambda (_path reason) (set! persist-failure reason))) (enclave-node-on-transfer-progress! (lambda (entry) (set! progress (cons entry progress)))) (pump-until "local companion authenticates" node-ready? 4000) (let* ((started (string-append marker "-invalidate-started")) (handle (source-session-open #{ provider: node: kind: pty: host: target argv: (list "sh" "-c" (string-append "printf '" started "\\n'; while read line; do printf 'live:%s\\n' \"$line\"; done")) cols: 80 rows: 24 } (lambda (event) (let ((bytes (dict-ref event bytes: #f))) (when bytes (set! output (string-append output bytes)))) (when (dict-ref event disconnected: #f) (set! lost (+ lost 1))) (when (dict-ref event error: #f) (set! session-error (dict-ref event error: #f)))) #{ window: 4096 })) (source #{ provider: node: kind: file: host: target address: writable }) (payload (string-repeat "paired-invalidation" 350000))) (pump-until "paired PTY starts" (lambda () (string-contains? output started)) 4000) (assert-equal 'direct (enclave-node-carrier-transport target)) (source-persist source payload) (pump-until "upload crosses at least one frame" (lambda () (find (lambda (entry) (and (eq? (dict-ref entry state: #f) 'active) (> (dict-ref entry completed: 0) 0))) progress)) 4000) (call-with-output-file armed-flag (lambda (port) (display "invalidate\n" port))) (pump-until "active paired work is invalidated" (lambda () (and persist-failure (> lost 0))) 4000) (assert-equal 1 lost) (assert-equal 0 (enclave-node-transfer-count)) (assert-equal 1 (length (filter (lambda (entry) (eq? (dict-ref entry state: #f) 'failed)) progress))) (assert-false (enclave-node-carrier-transport target)) (session-close handle)) (enclave-node-reset!) (node-disconnect!)))packages/slate/test/node-paired-offline-live.sgladded
;;; Hub-down paired-auth gate. Orchestrated by scripts/test-node-direct-live.(import (sigil core) (sigil async) (sigil path) (sigil process) (sigil string) (sigil test) (sigil time) (slate provider) (slate providers enclave-node) (slate providers node))(define companion (getenv "NODE_DIRECT_COMPANION_WS"))(define companion-token (getenv "NODE_DIRECT_COMPANION_TOKEN"))(define target (getenv "NODE_DIRECT_TARGET"))(define path (getenv "NODE_DIRECT_READABLE"))(define writable (getenv "NODE_DIRECT_WRITABLE"))(define marker (getenv "NODE_DIRECT_MARKER"))(test "restarted companions use cached pairing with the hub down" (with-async (define result #f) (define failure #f) (define output "") (define session-error #f) (define transfer-error #f) (define transfer-progress '()) (enclave-node-reset!) (node-reset!) (enclave-node-register!) (node-set-token! companion-token) (node-connect! companion) (define (pump-until label predicate limit) (let loop ((n 0)) (node-poll!) (enclave-node-poll!) (cond ((predicate) #t) ((>= n limit) (error (string-append "paired offline timeout: " label) failure session-error output (node-phase))) (else (sleep 0.01) (loop (+ n 1)))))) (pump-until "local companion authenticates" node-ready? 4000) (let* ((source #{ provider: node: kind: file: host: target address: path }) (op (provider-async-op source))) (op source (lambda (value) (set! result value)) (lambda (reason) (set! failure reason)))) (pump-until "paired resource request settles" (lambda () (or result failure)) 4000) (when failure (error "paired offline resource failed" failure)) (let ((text (if (bytevector? result) (utf8->string result) result))) (unless (and (string? text) (string-contains? text marker)) (error "paired offline resource returned wrong content" result))) (assert-equal 'direct (enclave-node-carrier-transport target)) (enclave-node-on-persist-error! (lambda (_path reason) (set! transfer-error reason))) (enclave-node-on-transfer-progress! (lambda (entry) (set! transfer-progress (cons entry transfer-progress)))) (let* ((session-marker (string-append marker "-offline-session")) (large-path (path-join (path-dirname writable) "paired-large.bin")) (large-source #{ provider: node: kind: file: host: target address: large-path }) (large-value (string-append (string-repeat "0123456789abcdef" 327680) marker "-paired-tail")) (handle (source-session-open #{ provider: node: kind: pty: host: target argv: (list "sh" "-c" (string-append "printf '" session-marker "\\n'; while read line; do printf 'offline:%s\\n' \"$line\"; done")) cols: 80 rows: 24 } (lambda (event) (let ((bytes (dict-ref event bytes: #f))) (when bytes (set! output (string-append output bytes)))) (when (dict-ref event error: #f) (set! session-error (dict-ref event error: #f)))) #{ window: 4096 }))) ;; Keep a PTY opening while a transfer above both the 3 MiB streaming ;; threshold and 4 MiB unary ceiling is active. This is the release gate ;; for correlation and flow-control isolation on a cached paired carrier. (source-persist large-source large-value) (pump-until "paired session and concurrent upload complete" (lambda () (or session-error transfer-error (and (string-contains? output session-marker) (= (enclave-node-pending-count) 0)))) 8000) (when session-error (error "paired offline session failed" session-error)) (when transfer-error (error "paired offline upload failed" transfer-error)) (assert-equal 0 (enclave-node-transfer-count)) (set! result #f) (set! failure #f) ((provider-async-op large-source) large-source (lambda (value) (set! result value)) (lambda (reason) (set! failure reason))) (pump-until "paired large download completes" (lambda () (or result failure)) 8000) (when failure (error "paired offline download failed" failure)) (assert-equal large-value result) (assert-equal 0 (enclave-node-transfer-count)) (for-each (lambda (entry) (unless (and (<= (dict-ref entry peak-outstanding: 4294967295) 262144) (<= (dict-ref entry peak-frame-bytes: 4294967295) 65536)) (error "paired transfer exceeded its flow-control bounds" entry))) transfer-progress) (unless (session-detach handle) (error "offline detach refused")) (unless (session-attach handle) (error "offline attach refused")) (session-write handle "reattached\n") (pump-until "paired session reattaches" (lambda () (string-contains? output "offline:reattached")) 4000) (assert-equal 'direct (enclave-node-carrier-transport target)) (session-close handle)) (enclave-node-reset!) (node-disconnect!)))packages/slate/test/node-paired-offline-multi-root-live.sgladded
;;; Two cached pairings used concurrently with Enclave stopped.(import (sigil core) (sigil async) (sigil process) (sigil string) (sigil test) (sigil time) (slate provider) (slate providers enclave-node) (slate providers node))(define companion (getenv "NODE_DIRECT_COMPANION_WS"))(define companion-token (getenv "NODE_DIRECT_COMPANION_TOKEN"))(define target-a (getenv "NODE_MULTI_ROOT_A"))(define target-b (getenv "NODE_MULTI_ROOT_B"))(define marker (getenv "NODE_DIRECT_MARKER"))(test "two hub-down paired roots keep independent sessions" (with-async (define output-a "") (define output-b "") (define lost-a 0) (define lost-b 0) (define error-a #f) (define error-b #f) (enclave-node-reset!) (node-reset!) (enclave-node-register!) (node-set-token! companion-token) (node-connect! companion) (define (pump-until label predicate limit) (let loop ((n 0)) (node-poll!) (enclave-node-poll!) (cond ((predicate) #t) ((>= n limit) (error (string-append "paired multi-root timeout: " label))) (else (sleep 0.01) (loop (+ n 1)))))) (pump-until "local companion authenticates" node-ready? 4000) (let* ((started-a (string-append marker "-paired-a")) (started-b (string-append marker "-paired-b")) (open (lambda (target prefix started set-output set-lost set-error) (source-session-open #{ provider: node: kind: pty: host: target argv: (list "sh" "-c" (string-append "printf '" started "\\n'; while read line; do printf '" prefix ":%s\\n' \"$line\"; done")) cols: 80 rows: 24 } (lambda (event) (let ((bytes (dict-ref event bytes: #f))) (when bytes (set-output bytes))) (when (dict-ref event disconnected: #f) (set-lost)) (when (dict-ref event error: #f) (set-error (dict-ref event error: #f)))) #{ window: 4096 }))) (a (open target-a "a" started-a (lambda (x) (set! output-a (string-append output-a x))) (lambda () (set! lost-a (+ lost-a 1))) (lambda (x) (set! error-a x)))) (b (open target-b "b" started-b (lambda (x) (set! output-b (string-append output-b x))) (lambda () (set! lost-b (+ lost-b 1))) (lambda (x) (set! error-b x))))) (pump-until "both paired PTYs start" (lambda () (or error-a error-b (and (string-contains? output-a started-a) (string-contains? output-b started-b)))) 8000) (when error-a (error "paired root A failed" error-a)) (when error-b (error "paired root B failed" error-b)) (assert-equal 'direct (enclave-node-carrier-transport target-a)) (assert-equal 'direct (enclave-node-carrier-transport target-b)) (session-write a "first\n") (session-write b "first\n") (pump-until "both paired roots exchange input" (lambda () (and (string-contains? output-a "a:first") (string-contains? output-b "b:first"))) 4000) (session-close a) (session-write b "after-a-close\n") (pump-until "root B survives root A close" (lambda () (string-contains? output-b "b:after-a-close")) 4000) (assert-equal 0 lost-a) (assert-equal 0 lost-b) (assert-equal 'direct (enclave-node-carrier-transport target-b)) (session-close b)) (enclave-node-reset!) (node-disconnect!)))packages/slate/test/node-paired-offline-reject-live.sgladded
;;; A paired-auth sabotage must never expose a usable carrier.(import (sigil core) (sigil async) (sigil process) (sigil test) (sigil time) (slate provider) (slate providers enclave-node) (slate providers node))(define companion (getenv "NODE_DIRECT_COMPANION_WS"))(define companion-token (getenv "NODE_DIRECT_COMPANION_TOKEN"))(define target (getenv "NODE_DIRECT_TARGET"))(define path (getenv "NODE_DIRECT_READABLE"))(test "mutated paired authentication fails closed with the hub down" (with-async (define result #f) (define failure #f) (enclave-node-reset!) (node-reset!) (enclave-node-register!) (node-set-token! companion-token) (node-connect! companion) (let ready ((n 0)) (node-poll!) (enclave-node-poll!) (cond ((node-ready?) #t) ((>= n 4000) (error "paired rejection: companion unavailable")) (else (sleep 0.01) (ready (+ n 1))))) (let* ((source #{ provider: node: kind: file: host: target address: path }) (op (provider-async-op source))) (op source (lambda (value) (set! result value)) (lambda (reason) (set! failure reason)))) (let settle ((n 0)) (node-poll!) (enclave-node-poll!) (cond ((or result failure) #t) ((>= n 4000) (error "paired rejection did not settle")) (else (sleep 0.01) (settle (+ n 1))))) (assert-false result) (assert-true failure) (assert-false (enclave-node-carrier-transport target)) (enclave-node-reset!) (node-disconnect!)))packages/slate/test/node-pairing-seed-live.sgladded
;;; Minimal broker-authenticated direct encounter used to seed durable pairing.(import (sigil core) (sigil async) (sigil process) (sigil string) (sigil test) (sigil time) (slate enclave-account session) (slate provider) (slate providers enclave-node) (slate providers node))(define endpoint (getenv "NODE_DIRECT_ENCLAVE_WS"))(define account (getenv "NODE_DIRECT_ACCOUNT"))(define credential (getenv "NODE_DIRECT_CREDENTIAL"))(define companion (getenv "NODE_DIRECT_COMPANION_WS"))(define companion-token (getenv "NODE_DIRECT_COMPANION_TOKEN"))(define target (getenv "NODE_DIRECT_TARGET"))(define path (getenv "NODE_DIRECT_READABLE"))(define marker (getenv "NODE_DIRECT_MARKER"))(test "a broker-authenticated direct encounter persists pairing" (with-async (define account-ready #f) (define account-failure #f) (define result #f) (define failure #f) (define running #t) (enclave-node-reset!) (node-reset!) (enclave-node-register!) (node-set-token! companion-token) (node-connect! companion) (account-session-on-message! enclave-node-on-message!) (account-session-start-saved! #{ version: 1 url: endpoint nick: account credential: credential } (lambda () (set! account-ready #t)) (lambda (reason) (set! account-failure reason))) (go (let loop () (account-session-poll!) (when running (sleep 0.001) (loop)))) (define (pump-until label predicate limit) (let loop ((n 0)) (node-poll!) (enclave-node-poll!) (cond ((predicate) #t) ((>= n limit) (error (string-append "pairing seed timeout: " label))) (else (sleep 0.01) (loop (+ n 1)))))) (pump-until "account and companion authenticate" (lambda () (and account-ready (node-ready?))) 4000) (let* ((source #{ provider: node: kind: file: host: target address: path }) (op (provider-async-op source))) (op source (lambda (value) (set! result value)) (lambda (reason) (set! failure reason)))) (pump-until "direct request settles" (lambda () (or result failure)) 4000) (when failure (error "pairing seed request failed" failure account-failure)) (unless (string-contains? (if (bytevector? result) (utf8->string result) result) marker) (error "pairing seed returned wrong content")) (assert-equal 'direct (enclave-node-carrier-transport target)) (set! running #f) (account-session-send-command! "ENCLAVE-NODES") (sleep 0.01) (enclave-node-reset!) (node-disconnect!) (account-session-close!)))packages/slate/test/node-provider-test.sglmodified
;; Resource plane — dired + file reads over bulk frames;; ============================================================(test "node home discovery uses the resource plane and returns an absolute home" (bring-ready!) (node-clear-sent!) (let ((out (make-vector 1 #f))) (node-fetch-home! (lambda (home) (vector-set! out 0 home))) (let ((req (find-req "fs/home"))) (assert-true (dict? req)) (assert-false (find-req "proc/run")) (deliver-ok! (dict-ref req id: 0) #{ path: "/data/data/com.termux/files/home" })) (assert-equal "/data/data/com.termux/files/home" (vector-ref out 0))))(test "materialize-async dir sends fs/read-dir, bulk reply → folder entries" (bring-ready!) (node-clear-sent!) (assert-eq (dict-ref (cadr entries) kind: #f) dir:) (assert-equal (dict-ref (car entries) size: #f) 5))))(test "materialize-async file: bulk bytevector → decoded string"(test "materialize-async file: credited chunks complete only at transfer end" (bring-ready!) (node-clear-sent!) (let ((out (make-vector 1 #f)) (lambda (c) (vector-set! out 0 c)) (lambda (e) #f)) (let ((req (find-req "fs/read-file"))) (assert-true (dict? req)) ;; read-file bulk payload is a wire-encoded bytevector (deliver-bulk! (dict-ref req id: 0) (string->utf8 "hello file\n"))) (let ((id (dict-ref req id: 0))) (node-deliver-text! (json-encode #{ t: "ok" id: id transfer: #{ direction: "download" size: 11 chunk-bytes: 65536 } })) (node-deliver-binary! (frame-encode #x20 id (bytevector 0 0 0 0 104 101 108 108 111 32))) (node-deliver-binary! (frame-encode #x20 id (bytevector 0 0 0 1 102 105 108 101 10))) (assert-false (vector-ref out 0)) (node-deliver-binary! (frame-encode #x21 id (bytevector 0 0 0 2 0 0 0 11))))) (assert-equal (vector-ref out 0) "hello file\n")))(test "stat-async: present path → kind; missing → #f"packages/slate/test/node-registry-cache-test.sgladded
(import (sigil core) (sigil test) (slate providers enclave-node))(test-group "node registry cache codec" (test "round-trips the secret-free discovery fields" (let* ((entries (list #{ label: "zerocool" nick: "opaque-1" home: "/home/dw" online: #t })) (decoded (registry-cache<-json (registry-cache->json entries)))) (assert-equal 1 (length decoded)) (assert-equal "zerocool" (dict-ref (car decoded) label: #f)) (assert-equal "opaque-1" (dict-ref (car decoded) nick: #f)) (assert-equal "/home/dw" (dict-ref (car decoded) home: #f)))) (test "corrupt, wrong-version and malformed entries fail closed" (assert-equal '() (registry-cache<-json "not-json")) (assert-equal '() (registry-cache<-json "{\"version\":2,\"entries\":[]}")) (assert-equal '() (registry-cache<-json "{\"version\":1,\"entries\":[{\"label\":\"missing-nick\"}]}"))))packages/slate/test/node-status-view-test.sgladded
(import (sigil core) (sigil string) (sigil test) (slate command) (slate commands navigation) (slate modes node-status) (slate pane) (slate space) (slate state) (slate view))(define (rendered-text value) (call-with-output-string (lambda (port) (write value port))))(test "node status command opens the common local-node view" (let* ((plain (view slate/name: "plain")) (state #{ slate/views: (list plain) slate/spaces: (list (make-space "main" (make-leaf 0 "plain") 0 1)) slate/active-space: "main" }) (shown (command-run state "node-status"))) (assert-true (node-status? (active-view shown))) (assert-equal node-status-view-name (view-name (active-view shown)))))(test "desktop and Termux status use the same renderer contract" (for-each (lambda (fixture) (let ((out (rendered-text (view-render (node-status-ready (make-node-status-view) fixture))))) (assert-true (string-contains? out "Reachable")) (assert-true (string-contains? out "Current connection")) (assert-true (string-contains? out "Supervisor")) (assert-true (string-contains? out "Schema")))) (list #{ schema-version: 3 platform: #{ kind: "desktop" environment: "native" } identity: #{ role: "local-node" scope: "local-device" } capability-posture: #{ resource: #{ available: #t } session: #{ available: #t } agent: #{ available: #t } renderer: #{ available: #t } remote-transport-manager: #{ available: #f reason: "not-implemented" } } installation: #{ posture: "not-installed" } supervision: #{ kind: "none" running: #f } process: #{ pid: 42 live: #t pid-state: "exact" } listener: #{ healthy: #t url: "http://127.0.0.1:32640/node/health" } lifecycle: #{ state: "missing" restart-count: 0 } autostart: #{ posture: "not-configured" } upgrade: #{ current-version: "0.1.0" state: "not-checked" check: #{ endpoint: "/node/api/version" } apply: #{ available: #f reason: "not-implemented" } rollback: #{ available: #f reason: "not-implemented" } installation: #{ strategy: "package-managed" } } app: #{ selection: "production" url: "https://useslate.org" } } #{ schema-version: 3 platform: #{ kind: "android" environment: "termux" } identity: #{ role: "local-node" scope: "local-device" } capability-posture: #{ resource: #{ available: #t } session: #{ available: #t } agent: #{ available: #t } renderer: #{ available: #t } remote-transport-manager: #{ available: #f reason: "not-implemented" } } installation: #{ posture: "installed" } supervision: #{ kind: "runit" running: #t } process: #{ pid: 84 live: #t pid-state: "exact" } listener: #{ healthy: #t url: "http://127.0.0.1:32640/node/health" } lifecycle: #{ state: "valid" restart-count: 2 } autostart: #{ posture: "configured" } upgrade: #{ current-version: "0.1.0" state: "not-checked" check: #{ endpoint: "/node/api/version" } apply: #{ available: #f reason: "not-implemented" } rollback: #{ available: #f reason: "not-implemented" } installation: #{ strategy: "atomic-version-directory" } } app: #{ selection: "custom" url: "http://127.0.0.1:32641" } })))(test "foreground connection is distinct from persistent installation" (let* ((fixture #{ schema-version: 1 platform: #{ kind: "android" environment: "termux" } installation: #{ posture: "not-installed" } supervision: #{ kind: "runit" running: #f } process: #{ pid: 32036 live: #t pid-state: "exact" } listener: #{ healthy: #t url: "http://127.0.0.1:32640/node/health" } lifecycle: #{ state: "missing" restart-count: 0 } autostart: #{ posture: "not-configured" } app: #{ selection: "production" url: "https://useslate.org" } }) (view (node-status-ready (make-node-status-view) fixture #t "ws://127.0.0.1:32660/node/ws")) (out (rendered-text (view-render view)))) (assert-true (string-contains? out "Connected")) (assert-true (string-contains? out "ws://127.0.0.1:32660/node/ws")) (assert-true (string-contains? out "Persistent installation")) (assert-true (string-contains? out "not-installed")) (assert-true (string-contains? out "http://127.0.0.1:32640/node/health"))))(test "local-node role and unavailable remote manager are explicit" (let* ((fixture #{ schema-version: 2 identity: #{ role: "local-node" scope: "local-device" } capability-posture: #{ resource: #{ available: #t } session: #{ available: #t } agent: #{ available: #t } renderer: #{ available: #t } remote-transport-manager: #{ available: #f reason: "not-implemented" } } listener: #{ healthy: #t } process: #{ live: #t } }) (out (rendered-text (view-render (node-status-ready (make-node-status-view) fixture))))) (assert-true (string-contains? out "Role and capabilities")) (assert-true (string-contains? out "local-node")) (assert-true (string-contains? out "Remote transport manager")) (assert-true (string-contains? out "not-implemented")) (assert-true (string-contains? out "does not grant"))))(test "upgrade posture is visible but mutation stays unavailable" (let* ((fixture #{ schema-version: 3 listener: #{ healthy: #t } process: #{ live: #t } upgrade: #{ current-version: "0.1.0" available-version: "0.2.0" state: "upgrade-available" check: #{ endpoint: "/node/api/version" } apply: #{ available: #f reason: "not-implemented" } rollback: #{ available: #f reason: "not-implemented" } installation: #{ strategy: "package-managed" } } }) (out (rendered-text (view-render (node-status-ready (make-node-status-view) fixture))))) (assert-true (string-contains? out "Node upgrade")) (assert-true (string-contains? out "0.2.0")) (assert-true (string-contains? out "package-managed")) (assert-true (string-contains? out "Upgrade checks are read-only"))))(test "unavailable and corrupt states remain explicit" (let* ((failed (node-status-failed (make-node-status-view) "connection refused")) (failure-text (rendered-text (view-render failed))) (corrupt (node-status-ready (make-node-status-view) #{ schema-version: 1 platform: #{ kind: "desktop" environment: "native" } listener: #{ healthy: #f } process: #{ live: #f pid-state: "stale" } lifecycle: #{ state: "corrupt" restart-count: 7 } })) (corrupt-text (rendered-text (view-render corrupt)))) (assert-true (string-contains? failure-text "connection refused")) (assert-true (string-contains? corrupt-text "corrupt")) (assert-true (string-contains? corrupt-text "Unavailable"))))(test-summary)packages/slate/test/node-target-restart-live.sgladded
;;; Target restart -> definitive old-session retirement without replacement.;;; Orchestrated only by scripts/test-node-direct-live.(import (sigil core) (sigil async) (sigil io) (sigil math) (sigil process) (sigil string) (sigil test) (sigil time) (slate enclave-account session) (slate provider) (slate providers enclave-node) (slate providers node))(define endpoint (getenv "NODE_DIRECT_ENCLAVE_WS"))(define account (getenv "NODE_DIRECT_ACCOUNT"))(define credential (getenv "NODE_DIRECT_CREDENTIAL"))(define companion (getenv "NODE_DIRECT_COMPANION_WS"))(define companion-token (getenv "NODE_DIRECT_COMPANION_TOKEN"))(define target (getenv "NODE_DIRECT_TARGET"))(define marker (getenv "NODE_DIRECT_MARKER"))(define restart-flag (getenv "NODE_TARGET_RESTART_FLAG"))(define sentinel (getenv "NODE_DIRECT_SESSION_SENTINEL"))(test "a target restart settles the old PTY without an implicit replacement" (with-async (define account-ready #f) (define account-failure #f) (define output "") (define terminal-error #f) (define lost 0) (define reattached 0) (define running #t) (enclave-node-reset!) (node-reset!) (enclave-node-register!) (node-set-token! companion-token) (node-connect! companion) (account-session-on-message! enclave-node-on-message!) (account-session-start-saved! #{ version: 1 url: endpoint nick: account credential: credential } (lambda () (set! account-ready #t)) (lambda (reason) (set! account-failure reason))) (go (let loop () (account-session-poll!) (when running (sleep 0.001) (loop)))) (define (pump-until label predicate limit) (let loop ((n 0)) (enclave-node-tick! (exact (floor (* 1000 (current-second))))) (node-poll!) (enclave-node-poll!) (cond ((predicate) #t) ((>= n limit) (error (string-append "target restart timeout: " label) account-failure terminal-error output lost reattached (enclave-node-reattach-count) (enclave-node-reattach-debug))) (else (sleep 0.01) (loop (+ n 1)))))) (pump-until "account and companion authenticate" (lambda () (and account-ready (node-ready?))) 4000) (let* ((started (string-append marker "-target-started")) (handle (source-session-open #{ provider: node: kind: pty: host: target argv: (list "sh" "-c" (string-append "if [ -e '" sentinel "' ]; then printf 'REPLACEMENT-PTY\\n'; exit 9; fi; : > '" sentinel "'; printf '" started "\\n'; while read line; do printf 'after:%s\\n' \"$line\"; done")) cols: 80 rows: 24 } (lambda (event) (let ((bytes (dict-ref event bytes: #f))) (when bytes (set! output (string-append output bytes)))) (when (dict-ref event disconnected: #f) (set! lost (+ lost 1))) (when (dict-ref event reattached: #f) (set! reattached (+ reattached 1))) (when (dict-ref event error: #f) (set! terminal-error (dict-ref event error: #f)))) #{ window: 4096 }))) (pump-until "original PTY starts" (lambda () (string-contains? output started)) 4000) (assert-equal 'relay (enclave-node-carrier-transport target)) (call-with-output-file restart-flag (lambda (port) (display "restart\n" port))) (pump-until "target carrier reports one loss" (lambda () (> lost 0)) 6000) (assert-equal 1 lost) (pump-until "restarted target definitively refuses the dead session" (lambda () terminal-error) 8000) (assert-true (string-contains? terminal-error "no-session")) (assert-equal 0 reattached) (assert-equal 0 (enclave-node-reattach-count)) (when (string-contains? output "REPLACEMENT-PTY") (error "target restart opened an implicit replacement PTY")) (session-close handle)) (set! running #f) ;; Native irc-ws-receive parks until one frame arrives. Wake the account ;; worker so it can observe RUNNING=#f before with-async joins its children. (account-session-send-command! "ENCLAVE-NODES") (sleep 0.01) (enclave-node-reset!) (node-disconnect!) (account-session-close!)))packages/slate/test/node-transfer-loss-live.sgladded
;;; Multi-frame Resource interruption gate. Orchestrated by;;; scripts/test-node-direct-live with only the advertised direct carrier behind;;; slate-netem; Enclave control and relay remain healthy.(import (sigil core) (sigil async) (sigil io) (sigil path) (sigil process) (sigil string) (sigil test) (sigil time) (slate enclave-account session) (slate provider) (slate providers enclave-node) (slate providers node))(define endpoint (getenv "NODE_DIRECT_ENCLAVE_WS"))(define account (getenv "NODE_DIRECT_ACCOUNT"))(define credential (getenv "NODE_DIRECT_CREDENTIAL"))(define companion (getenv "NODE_DIRECT_COMPANION_WS"))(define companion-token (getenv "NODE_DIRECT_COMPANION_TOKEN"))(define target (getenv "NODE_DIRECT_TARGET"))(define writable (getenv "NODE_DIRECT_WRITABLE"))(define recovery (getenv "NODE_DIRECT_RECOVERY"))(define direction (getenv "NODE_TRANSFER_DIRECTION"))(define expected (if (string=? (getenv "NODE_TRANSFER_EXPECT_TRANSPORT") "direct") 'direct 'relay))(define payload-size (* 5 1024 1024))(define payload (string-repeat "x" payload-size))(define sentinel "atomic-sentinel")(test "an interrupted multi-frame Resource transfer fails and cleans up" (with-async (define account-ready #f) (define account-failure #f) (define result #f) (define failure #f) (define persist-failure #f) (define progress '()) (define running #t) (enclave-node-reset!) (node-reset!) (enclave-node-register!) (node-set-token! companion-token) (node-connect! companion) (account-session-on-message! enclave-node-on-message!) (account-session-start-saved! #{ version: 1 url: endpoint nick: account credential: credential } (lambda () (set! account-ready #t)) (lambda (reason) (set! account-failure reason))) (go (let loop () (account-session-poll!) (when running (sleep 0.001) (loop)))) (define (pump-until label predicate limit) (let loop ((n 0)) (node-poll!) (enclave-node-poll!) (cond ((predicate) #t) ((>= n limit) (error (string-append "transfer loss timeout: " label) account-failure failure persist-failure (enclave-node-transfer-count) (node-phase))) (else (sleep 0.01) (loop (+ n 1)))))) (define (failed-progress-count) (length (filter (lambda (entry) (eq? (dict-ref entry state: #f) 'failed)) progress))) (define (active-progress?) (find (lambda (entry) (and (eq? (dict-ref entry state: #f) 'active) (> (dict-ref entry completed: 0) 0) (< (dict-ref entry completed: payload-size) payload-size))) progress)) (define (assert-high-water) (for-each (lambda (entry) (unless (and (<= (dict-ref entry peak-outstanding: 4294967295) 262144) (<= (dict-ref entry peak-frame-bytes: 4294967295) 65536)) (error "transfer exceeded its credit/frame high-water" entry))) progress)) (define source #{ provider: node: kind: file: host: target address: writable }) (define recovery-source #{ provider: node: kind: file: host: target address: recovery }) (enclave-node-on-persist-error! (lambda (_path reason) (set! persist-failure reason))) (enclave-node-on-transfer-progress! (lambda (entry) (set! progress (cons entry progress)))) (pump-until "account and companion authenticate" (lambda () (and account-ready (node-ready?))) 4000) (if (string=? direction "upload") (source-persist source payload) ((provider-async-op source) source (lambda (value) (set! result value)) (lambda (reason) (set! failure reason)))) (pump-until "multiple transfer frames are acknowledged" active-progress? 4000) (assert-equal expected (enclave-node-carrier-transport target)) (assert-equal 1 (enclave-node-transfer-count)) ;; The scenario now cuts the only direct carrier. The in-flight operation ;; must fail exactly once; it must not finish or migrate onto relay. (pump-until "the interrupted transfer fails" (lambda () (if (string=? direction "upload") persist-failure failure)) 4000) (assert-equal 0 (enclave-node-transfer-count)) (assert-equal 1 (failed-progress-count)) (assert-high-water) (when result (error "interrupted download completed")) ;; A fresh request may select relay. For upload, the destination must still ;; contain its pre-transfer sentinel. For download, the complete seeded file ;; must now arrive byte-for-byte through the newly selected carrier. ;; Cutting the target's Enclave connection makes the relay target briefly ;; unavailable. Allow the scenario to enter recovery before issuing a new ;; request; direct-carrier cuts keep Enclave healthy and need no delay. (when (eq? expected 'relay) (let loop ((n 0)) (when (< n 1200) (node-poll!) (enclave-node-poll!) (sleep 0.01) (loop (+ n 1))))) (set! result #f) (set! failure #f) (let ((fresh-source (if (string=? direction "upload") source recovery-source))) ((provider-async-op fresh-source) fresh-source (lambda (value) (set! result value)) (lambda (reason) (set! failure reason)))) (pump-until "fresh relay read completes" (lambda () (or result failure)) 4000) (when failure (error "fresh relay read failed" failure)) (if (string=? direction "upload") (assert-equal sentinel result) (assert-equal "relay-recovery" result)) (assert-equal 'relay (enclave-node-carrier-transport target)) (assert-equal 0 (enclave-node-transfer-count)) (set! running #f) (account-session-send-command! "ENCLAVE-NODES") (sleep 0.01) (enclave-node-reset!) (node-disconnect!) (account-session-close!)))packages/slate/test/performance-capture-policy-test.mjsadded
import assert from 'node:assert/strict';import fs from 'node:fs';const html = fs.readFileSync(new URL('../assets/index.html', import.meta.url), 'utf8');const main = fs.readFileSync(new URL('../src/slate/main.sgl', import.meta.url), 'utf8');const help = fs.readFileSync(new URL('../src/slate/commands/help.sgl', import.meta.url), 'utf8');const enclaveNode = fs.readFileSync(new URL('../src/slate/providers/enclave-node.sgl', import.meta.url), 'utf8');const sessions = fs.readFileSync(new URL('../../slate-cli/src/slate-cli/sessions.sgl', import.meta.url), 'utf8');const wire = fs.readFileSync(new URL('../../slate-cli/src/slate-cli/wire.sgl', import.meta.url), 'utf8');// Palette profiling is opt-in: the normal input path must not even read the// clock, while an active capture labels the synchronous model/render path.assert.match(html, /const measuring = window\.__slatePerfActive && window\.__slatePerfActive\(\);/);assert.match(html, /const inputBegin = measuring \? performance\.now\(\) : 0;/);assert.match(html, /slateEvalRaw\('\(slate-selector-set[\s\S]*?'palette'\);/);assert.match(html, /slateEvalString\('\(slate-selector-set-profile/);// Query edits take the selector-only host fast path regardless of whether a// capture is active; profiling merely decides whether timestamps are recorded.assert.match(html, /renderContext === 'palette-scheduled'/);assert.match(html, /syncSelector\(\); animateSelectorEntrance\(\); scrollSelectedIntoView\(\);/);assert.match(html, /afterRender\(selectorOpen\(\) \? 'palette-scheduled' :\s*'scheduled-' \+ \(source \|\| 'unspecified'\)\);/);assert.match(html, /const perfDetail = renderContext && window\.__slatePerfActive/);// The opaque WASM update is separated from the host post-render work, whose// useful groups expose whether selector/layout/service reconciliation dominates.for (const phase of [ 'wasm-eval-render-patch', 'after-render-total', 'after-render-fields', 'after-render-view-bridges', 'after-render-selector', 'after-render-layout-focus', 'after-render-services', 'input-model-total', 'terminal-node-tick', 'terminal-enclave-tick', 'terminal-registry-poll', 'terminal-lantern-poll', 'terminal-browserfs-poll', 'terminal-frame-extract', 'terminal-frame-parse', 'terminal-dom-apply', 'terminal-arm-to-frame', 'terminal-dom-to-present', 'terminal-output-paint', 'terminal-key-to-wire-send', 'terminal-wire-send-call', 'terminal-remote-write', 'terminal-remote-output', 'terminal-wire-roundtrip', 'terminal-key-to-wire-arrival', 'terminal-receive-queue', 'terminal-model-apply', 'after-render-source', 'after-render-schedule-source', 'after-render-mutation-target', 'terminal-arm-source', 'terminal-invalidate-source', 'terminal-flush-source',]) { assert.ok(html.includes(`'${phase}'`), `missing performance phase ${phase}`);}assert.match(html, /name\.endsWith\('-ms'\)[\s\S]*?'palette-' \+ name/);assert.match(html, /'palette-candidate-count'/);assert.match(html, /'palette-visible-count'/);assert.match(main, /define \(slate-selector-set-profile query\)/);assert.match(main, /model-ms: \(profile-ms t0 t1\)/);assert.match(main, /candidates-ms: \(profile-ms t1 t2\)/);assert.match(main, /ranking-ms: \(profile-ms t2 t3\)/);assert.match(main, /sxml-ms: \(profile-ms t3 t4\)/);assert.match(main, /patch-ms: \(profile-ms t4 t5\)/);// Full-session summaries and raw diagnostics have independent bounds, and the// serialized request has a final guard below the node's 16 KiB ceiling.assert.match(html, /const rawLimit = 96;/);assert.match(html, /const groupLimit = 512;/);assert.match(html, /if \(samples\.length > rawLimit\) samples\.splice/);assert.match(html, /if \(values\.length > groupLimit\) values\.splice/);assert.match(html, /new TextEncoder\(\)\.encode\(body\)\.length > 14000/);assert.match(html, /report\.raw_samples_omitted = true;/);assert.match(html, /terminalPaintRaf = requestAnimationFrame/, 'terminal paint latency must end at an animation frame');assert.match(html, /eventType === 3 && window\.__slatePerfWebSocketArrival/, 'binary WebSocket arrival must be measured before the coalesced WASM drain');assert.match(html, /latestKeyAt !== terminalWireTracedKeyAt && binaryPayload\(data\)/, 'wire timing must select exactly the first synchronous binary send per terminal key');assert.match(html, /WebSocket\.prototype\.send = originalWebSocketSend/, 'stopping a capture must remove the WebSocket probe');assert.doesNotMatch(html, /binaryPayload[\s\S]{0,700}(textContent|JSON\.stringify|decoder|bytes\[)/, 'wire timing must not retain or decode typed terminal content');assert.match(html, /slate-performance-trace-set #t/, 'starting a client capture must explicitly enable node markers');assert.match(html, /slate-performance-trace-set #f/, 'saving a client capture must disable node markers');assert.match(wire, /define frame-trace-in\s+#x0a/);assert.match(wire, /define frame-trace-out\s+#x0b/);assert.match(sessions, /frame-trace-in[\s\S]*?trace-register!/, 'the remote node must accept correlation markers separately from input');assert.match(sessions, /frame-trace-out[\s\S]*?trace-payload/, 'the remote node must return timing separately from terminal output');assert.doesNotMatch(sessions, /trace-payload[\s\S]{0,500}(body|bytes)/, 'returned trace metadata must not contain terminal payload bytes');// The Enclave metronome follows the node tick's damage contract. An idle poll// must not run afterRender, because TERM.ensure would turn it into a full-grid// terminal invalidation every 150ms.assert.doesNotMatch(html, /enclaveTimer = setInterval\(\(\) => \{\s*slateEvalRaw/);assert.match(html, /slateEvalString\('\(slate-enclave-tick[\s\S]*?r === '2'\) afterRender\('enclave-tick'\);[\s\S]*?r === '1'\) TERM\.arm\('enclave-tick'\);/);assert.match(main, /define \(slate-enclave-tick[\s\S]*?\(> \*refresh-count\* before\) "2"[\s\S]*?\(drained\? "1"\)/);assert.match(enclaveNode, /define \(enclave-node-poll!\)[\s\S]*?\(if drained\? "1" ""\)/);assert.doesNotMatch(html, /echoTimer\s*=|slate-echo-tick/, 'echo dismissal must not poll the WASM runtime');assert.match(html, /addEventListener\('animationend'/);assert.match(html, /slate-echo-animation-done/);assert.match(main, /define \(slate-echo-animation-done generation\)/);assert.match(html, /classList\.contains\('echo-active'\)\) el\.classList\.remove\('echo-active'\)/, 'idle echo sync must not rewrite the ledge class attribute');assert.match(html, /el\.style\.animationPlayState !== playState/, 'echo sync must not rewrite an unchanged ledge style attribute');assert.match(html, /target\.closest && target\.closest\('\.term-content'\)/, 'terminal-owned row mutations must not schedule a full afterRender pass');assert.match(main, /define \(slate-accessory-key spec\)[\s\S]*?before \*refresh-count\*[\s\S]*?rendered\? \(> \*refresh-count\* before\)/, 'accessory input must report whether it actually rendered Slate');assert.match(html, /r === '3' \|\| r === '4'\) afterRender\('accessory-render'\)/, 'only rendered accessory actions may run full host reconciliation');assert.match(main, /term-damage\? \(and r \(not rendered\?\)[\s\S]*?terminal-frame-pending\? \(view-name v\)/, 'accessory input must report actual synchronous terminal damage');assert.match(html, /r === '5'[\s\S]*?TERM\.arm\('accessory-terminal'\)/, 'only actual synchronous terminal damage may arm the terminal pump');assert.match(html, /if \(!selectorOpen\(\) && document\.querySelector\('\.pane\.focused \.term-content'\)\) return;\s*scheduleAfterRender\('keydown'\)/, 'terminal keys must not enter the editor-only full reconciliation repair');assert.match(html, /if \(selectorOpen\(\) \|\|[\s\S]*?!document\.querySelector\('\.pane\.focused \.term-content'\) \|\|[\s\S]*?event\.target\.id === 'slate-key-capture'/, 'terminal key flush must ignore palettes, other views, and explicitly armed mobile input');assert.match(html, /const BRIDGE_BUSY_MS = 16, BRIDGE_IDLE_MS = 250/, 'host bridges must retain a fast busy cadence and back off while idle');assert.match(html, /const BRIDGE_TERMINAL_IDLE_MS = 1000/, 'idle non-terminal bridges must back off while a terminal is focused');assert.match(html, /if \(r === '1'\) terminalPaintPriorityUntil = performance\.now\(\) \+ 80/, 'received terminal output must reserve its immediate paint window');assert.match(html, /if \(!terminalPaintPriorityActive\(\)\) enclaveTickOnce\('timer'\)/, 'an incidental Enclave timer must yield to an armed terminal paint');assert.match(html, /function registryDrain\(\)[\s\S]*?terminalPaintPriorityActive\(\)[\s\S]*?registrySchedule/, 'idle registry work must yield to an armed terminal paint');assert.match(html, /function bfsDrain\(\)[\s\S]*?terminalPaintPriorityActive\(\)[\s\S]*?bfsSchedule/, 'idle BrowserFS work must yield to an armed terminal paint');assert.doesNotMatch(html, /setInterval\(bfsDrain/, 'BrowserFS must not enter WASM continuously while idle');assert.doesNotMatch(html, /registryTimer = setInterval/, 'the package registry must not enter WASM continuously while idle');assert.match(html, /registryKick\(\);[\s\S]*?__slateBrowserFsKick/, 'normal renders must wake both adaptive bridges immediately');assert.match(html, /net\.createImports\(\{ onEvent: netEventWake \}\)/, 'WebSocket arrivals must wake the in-WASM transport drain');assert.match(html, /if \(nodeTimer !== null\) nodeTickOnce\('event'\)/, 'arrival wakes must retain the node timer as the active/recovery gate');assert.match(html, /if \(source === 'event'\) TERM\.flush\('node-event'\)/, 'event-driven terminal output must paint in its arrival microtask');assert.match(html, /return \{ ensure, arm, invalidate, flush: flushNow \}/, 'the terminal kernel must expose only its bounded synchronous flush');assert.match(html, /if \(netWakePending\) return;[\s\S]*?queueMicrotask/, 'arrival bursts must coalesce before entering WASM');assert.doesNotMatch(html, /netEventWake[\s\S]{0,300}enclaveTickOnce/, 'node socket arrivals must not add an Enclave poll before paint');assert.match(html, /nodeTimer = setInterval\([\s\S]*?nodeTickOnce\('timer'\);[\s\S]*?\}, 150\)/, 'the event-driven node fallback must retain the FSM recovery cadence');// Query edits patch a sibling palette mount. It remains a projection of the// canonical Slate state and uses the canonical key handler; broader refreshes// update the frame and palette together.assert.match(html, /<div id="app"><\/div>\s*<div id="palette-root"><\/div>/);assert.match(main, /define \(render-palette-mount\/content state content\)[\s\S]*?on-key: ,slate-handle-key/);assert.match(main, /define \(render-palette-mount state\)[\s\S]*?render-palette-mount\/content/);assert.match(main, /define \(slate-selector-refresh\)[\s\S]*?web-client-update "#palette-root"/);assert.match(main, /define \(slate-refresh\)[\s\S]*?web-client-update "#app"[\s\S]*?slate-selector-refresh/);assert.match(main, /define \(slate-selector-set query\)[\s\S]*?slate-selector-refresh/);assert.doesNotMatch(main, /accessory-keyboard-deck[\s\S]{0,160}\(palette state\)/);assert.match(main, /define \*selector-max-rows\* 50/);assert.match(main, /define \(effective-prompt-candidates state desc\)/);assert.match(main, /prompt-candidate-policy desc\) snapshot:/);assert.match(main, /slate\/prompt-session:/);assert.match(help, /candidate-policy: live:/, 'query-generated describe-command candidates must remain live');console.log('performance capture policy: opt-in palette phases and bounded reports');packages/slate/test/preferences-store-test.sglmodified
(define option-keys (list slate/theme-name: slate/effects: slate/translucent: slate/focus-follows-mouse: slate/terminals-use-tmux: slate/keep-screen-awake: slate/opacity: slate/chrome-opacity: slate/ui-text-size: slate/text-scale: slate/thumb-side:)) (fs-materialize kv #{ kind: file: provider: browser: address: "/store/options.sgv" })))(test "all ten built-in preferences are declared"(test "all eleven built-in preferences are declared" (for-each (lambda (key) (let ((declaration (option-declaration key))) (assert-equal (dict-keys (options<-text "not a datum at all")) '()) (assert-equal (dict-keys (options<-text "(one two)")) '()))(test "fresh profile boots all ten defaults without inventing stored decisions"(test "fresh profile boots all eleven defaults without inventing stored decisions" (let* ((result (preferences-restore-from #{} #{} #f)) (state (dict-ref result state: #f)) (stored (dict-ref result stored: #f))) (assert-equal (dict-ref state slate/translucent: #t) #f) (assert-equal (dict-ref state slate/focus-follows-mouse: #f) #t) (assert-equal (dict-ref state slate/terminals-use-tmux: #f) #t) (assert-equal (dict-ref state slate/keep-screen-awake: #t) #f) (assert-equal (dict-ref state slate/opacity: #f) 72) (assert-equal (dict-ref state slate/chrome-opacity: #f) 86) (assert-equal (dict-ref state slate/ui-text-size: #f) 15) (assert-equal (dict-ref state slate/thumb-side: #f) 'left) (assert-equal (dict-keys stored) '())))(test "store-backed profile restores all ten typed values"(test "store-backed profile restores all eleven typed values" (let* ((stored #{ slate/theme-name: "slate-light" slate/effects: #f slate/translucent: #t slate/focus-follows-mouse: #f slate/terminals-use-tmux: #f slate/keep-screen-awake: #t slate/opacity: 55 slate/chrome-opacity: 77 slate/ui-text-size: 20packages/slate/test/profile-git-diff.mjsadded
// Read-only live profiling: the same changed file with real or disabled watches.// GIT_PROFILE_WATCHES=off only replaces subscribe in this disposable client.// Build first: sigil build slate-cli --config node-release// Keep the worktree unchanged until all samples finish (snapshot validation).import assert from 'node:assert/strict';import fs from 'node:fs';import net from 'node:net';import os from 'node:os';import path from 'node:path';import { createHash } from 'node:crypto';import { spawn } from 'node:child_process';const workspace = process.cwd();const repo = process.env.GIT_PROFILE_REPO || workspace;const file = process.env.GIT_PROFILE_FILE || 'packages/slate-cli/src/slate-cli/git/diff.sgl';const watches = process.env.GIT_PROFILE_WATCHES !== 'off';const samples = Number(process.env.GIT_PROFILE_SAMPLES || 5);assert.ok(Number.isInteger(samples) && samples > 0 && samples <= 30, 'GIT_PROFILE_SAMPLES must be an integer from 1 to 30');const binary = process.env.GIT_PROFILE_BINARY || 'build/node-release/bin/slate';const binarySha256 = createHash('sha256').update(fs.readFileSync(binary)).digest('hex');const output = process.env.GIT_PROFILE_OUTPUT || `/tmp/slate-diff-profile-${watches ? 'on' : 'off'}`;const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));const port = await new Promise((resolve, reject) => { const server = net.createServer(); server.once('error', reject); server.listen(0, '127.0.0.1', () => { const chosen = server.address().port; server.close(error => error ? reject(error) : resolve(chosen)); });});const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'slate-diff-profile-'));const daemon = spawn(binary, ['serve', '--app-dir', 'build/web', '--port', String(port), '--log-level', 'debug'], { cwd: workspace, stdio: ['ignore', 'pipe', 'pipe'] });let daemonLog = '', chrome, socket;daemon.stdout.on('data', b => { daemonLog += b; });daemon.stderr.on('data', b => { daemonLog += b; });const runtime = [];const started = new Date().toISOString();const loadBefore = os.loadavg();try { const url = `http://127.0.0.1:${port}/`; for (let i = 0; ; i++) { try { if ((await fetch(url)).ok) break; } catch (_) {} assert.ok(i < 200, 'daemon startup timeout'); await sleep(100); } chrome = spawn('google-chrome', ['--headless=new', '--no-sandbox', '--disable-gpu', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdio: 'ignore' }); const activePort = path.join(profile, 'DevToolsActivePort'); for (let i = 0; !fs.existsSync(activePort); i++) { assert.ok(i < 200, 'Chrome startup timeout'); await sleep(100); } const [debugPort, browserPath] = fs.readFileSync(activePort, 'utf8').trim().split('\n'); socket = new WebSocket(`ws://127.0.0.1:${debugPort}${browserPath}`); await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; }); let id = 0; const pending = new Map(); socket.onmessage = ({ data }) => { const msg = JSON.parse(data); if (msg.method === 'Runtime.consoleAPICalled') runtime.push(msg.params.args.map(a => a.value ?? a.description).join(' ')); if (msg.method === 'Runtime.exceptionThrown') runtime.push(msg.params.exceptionDetails.exception?.description || msg.params.exceptionDetails.text); if (msg.id && pending.has(msg.id)) { const p = pending.get(msg.id); pending.delete(msg.id); msg.error ? p.reject(new Error(JSON.stringify(msg.error))) : p.resolve(msg.result); } }; const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => { const call = ++id; pending.set(call, { resolve, reject }); socket.send(JSON.stringify({ id: call, method, params, ...(sessionId ? { sessionId } : {}) })); }); const target = await send('Target.createTarget', { url: 'about:blank' }); const { sessionId } = await send('Target.attachToTarget', { targetId: target.targetId, flatten: true }); await send('Runtime.enable', {}, sessionId); const evaluate = async expression => { const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, sessionId); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text); return r.result.value; }; const waitFor = async (expression, label) => { for (let i = 0; i < 600; i++) { if (await evaluate(expression)) return; await sleep(100); } throw new Error(`timeout: ${label}; runtime=${runtime.join(' | ')}; body=${await evaluate('document.body.innerText.slice(0, 1800)')}`); }; await send('Page.enable', {}, sessionId); await send('Page.addScriptToEvaluateOnNewDocument', { source: ` window.gitProfile = {wire:[], samples:[]}; const BaseWebSocket = window.WebSocket; window.WebSocket = class extends BaseWebSocket { constructor(...args) { super(...args); const requests = new Map(); const baseSend = this.send; this.send = function(data) { try { const m = JSON.parse(data); if (m.verb?.startsWith('git/') || m.verb === 'fs/watch') { requests.set(m.id, m.verb); window.gitProfile.wire.push({phase:'send', verb:m.verb, id:m.id, cancel:m.args?.cancel === true, at:performance.now()}); } } catch (_) {} return baseSend.call(this, data); }; this.addEventListener('message', event => { try { const m = JSON.parse(event.data), verb = requests.get(m.id); if (verb && (m.t === 'ok' || m.t === 'err')) { window.gitProfile.wire.push({phase:'receive', verb, id:m.id, type:m.t, at:performance.now()}); requests.delete(m.id); } } catch (_) {} }); } }; ` }, sessionId); await send('Page.navigate', { url: `${url}?rbdebug` }, sessionId); await waitFor(`document.getElementById('status')?.textContent === 'ready' && window.slateEval?.('(slate-node-connected?)') === '1'`, 'boot'); const sgl = form => evaluate(`window.slateEval(${JSON.stringify(form)})`); if (!watches) await sgl(`(begin (import (slate provider)) (register-provider! local: (dict-set (provider-ref local:) subscribe: (lambda (source callback) (lambda () #t)))) #t)`); await sgl(`(slate-dispatch ${JSON.stringify(`md-visit:local:${path.join(repo, file)}`)})`); await waitFor(`document.querySelector('.editor-input, .editor-content')`, 'source file'); await sgl('(slate-dispatch "git-status")'); const row = `[...document.querySelectorAll('.git-entry')].find(e => e.textContent.includes(${JSON.stringify(file)}))`; await waitFor(row, 'changed file in status'); await evaluate(`new MutationObserver(() => { const p=window.gitProfile, sample=p.active; if (!sample) return; if (!sample.loading && document.body.innerText.includes('Loading diff')) sample.loading=performance.now(); if (!sample.rendered && document.querySelector('.git-status .slate-diff-addition, .git-status .slate-diff-deletion')) { sample.rendered=performance.now(); requestAnimationFrame(() => { sample.frame=performance.now(); }); } }).observe(document.getElementById('app'), {subtree:true, childList:true, characterData:true});`); for (let i = 0; i < samples; i++) { await evaluate(`(() => { const p=window.gitProfile; p.active={start:performance.now(), wireStart:p.wire.length}; p.samples.push(p.active); (${row}).querySelector('.git-entry-toggle').click(); p.active.dispatchReturned=performance.now(); })()`); await waitFor('window.gitProfile.active?.frame', `diff sample ${i}`); await evaluate(`window.gitProfile.active=null; (${row}).querySelector('.git-entry-toggle').click()`); await waitFor(`!document.querySelector('.git-status .slate-diff-row')`, 'collapse'); await sleep(350); } const timings = await evaluate('window.gitProfile'); const measured = timings.samples.map(sample => { const sent = timings.wire.slice(sample.wireStart) .find(e => e.phase === 'send' && e.verb === 'git/diff' && !e.cancel); const received = sent && timings.wire.find(e => e.phase === 'receive' && e.id === sent.id && e.at >= sent.at); assert.ok(sent && received && received.type === 'ok', 'sample must contain a successful diff exchange'); return { totalMs:sample.frame-sample.start, dispatchMs:sample.dispatchReturned-sample.start, loadingMs:sample.loading ? sample.loading-sample.start : null, actionToSendMs:sent.at-sample.start, roundTripMs:received.at-sent.at, receiveToDomMs:sample.rendered-received.at, receiveToFrameMs:sample.frame-received.at }; }); const result = {started, binary, binarySha256, watches, repo, file, loadBefore, loadAfter:os.loadavg(), logicalCpus:os.cpus().length, measured, wire:timings.wire, daemonDurations:[...daemonLog.matchAll(/node\/rpc-end[^\n]*verb=(git\/[^ ]+|fs\/watch)[^\n]*duration-ms=([0-9.]+)/g)] .map(m => ({verb:m[1], ms:Number(m[2])})), diffPhases:[...daemonLog.matchAll(/node\/git-diff-phase[^\n]*phase=([^ ]+)[^\n]*duration-ms=([0-9.]+)/g)] .map(m => ({phase:m[1], ms:Number(m[2])})), runtime}; fs.writeFileSync(`${output}.json`, JSON.stringify(result, null, 2)); console.log(JSON.stringify({watches, measured, output:`${output}.json`}));} finally { fs.writeFileSync(`${output}.daemon.log`, daemonLog); if (socket) socket.close(); if (chrome) chrome.kill('SIGTERM'); daemon.kill('SIGTERM'); await sleep(1000); fs.rmSync(profile, {recursive:true, force:true, maxRetries:5, retryDelay:100});}packages/slate/test/prompt-test.sglmodified
(define (cand label action) #{ label: label action: action })(test "candidate lifecycle defaults by prompt type" (assert-eq (prompt-candidate-policy (completing-read-prompt "Choices" (lambda (st) '()) (lambda (st v) st))) snapshot:) (assert-eq (prompt-candidate-policy (file-prompt "File" (lambda (st) '()) (lambda (st v) st) (lambda (st q) q) #f)) directory:) (assert-eq (prompt-candidate-policy (string-prompt "Text" (lambda (st v) #t) (lambda (st v) st))) live:))(test "opening a prompt advances its candidate snapshot session" (let* ((first (open-prompt ST "one" (list "one"))) (second (open-prompt first "two" (list "two")))) (assert-equal (dict-ref first slate/prompt-session: 0) 1) (assert-equal (dict-ref second slate/prompt-session: 0) 2)));; A continuation that records what it was handed, so a test can assert on the;; VALUE rather than on a state mutation.(define (recorder)packages/slate/test/provider-replay-test.sglmodified
(assert-false (dict-ref seen disconnected: #f)) (assert-false (dict-ref seen code: #f)))) (test "it carries the explicitly selected reattach transport when supplied" (let ((seen #f)) (source-session-open (a-source) (lambda (ev) (when (dict-ref ev reattached: #f) (set! seen ev))) #{}) ((dict-ref *ctx* reattached: #f) 12 345 'relay) (assert-equal 'relay (dict-ref seen transport: #f)))) (test "`#f` BYTES STILL ANNOUNCES THE RE-ATTACH — less information, not wrong" ;; THE SURFACE DEGRADES TO SILENCE ON THE NUMBER, NEVER TO A WRONG NUMBER. ;;packages/slate/test/resource-transfer-test.sgladded
(import (sigil test) (sigil core) (sigil math) (slate resource transfer))(define (u32 n) (bytevector (modulo (quotient n 16777216) 256) (modulo (quotient n 65536) 256) (modulo (quotient n 256) 256) (modulo n 256)))(test-group "renderer Resource transfers" (test "upload waits for credit and progress advances only on acknowledgement" (let ((r (make-resource-transfer-registry)) (sent '()) (progress '()) (done #f)) (resource-transfer-upload! r 1 'carrier-7 (bytevector 1 2 3 4 5) 'direct (lambda (f) (set! sent (append sent (list f)))) (lambda (_) (set! done #t)) (lambda (_) #f) (lambda (p) (set! progress (append progress (list p))))) (resource-transfer-control! r #{ t: "ok" id: 1 transfer: #{ direction: "upload" size: 5 chunk-bytes: 65536 } }) (assert-equal 0 (length sent)) (resource-transfer-frame! r (transfer-frame-encode #x23 1 (u32 5))) (assert-equal 2 (length sent)) ; batch + end (assert-equal 0 (dict-ref (car progress) completed: -1)) ; initial credit is not ack (resource-transfer-frame! r (transfer-frame-encode #x23 1 (u32 5))) (assert-equal 5 (dict-ref (cadr progress) completed: -1)) (assert-false done) (resource-transfer-control! r #{ t: "ok" id: 1 result: #{ bytes: 5 } }) (assert-true done) (assert-equal 5 (dict-ref (car (reverse progress)) peak-outstanding: -1)) (assert-equal 5 (dict-ref (car (reverse progress)) peak-frame-bytes: -1)) (assert-equal 0 (resource-transfer-count r)))) (test "download grants credit, assembles exact bytes, and completes on end" (let ((r (make-resource-transfer-registry)) (sent '()) (result #f) (progress '())) (resource-transfer-download! r 2 'local-3 'local (lambda (f) (set! sent (append sent (list f)))) (lambda (bytes) (set! result bytes)) (lambda (_) #f) (lambda (entry) (set! progress (cons entry progress)))) (resource-transfer-control! r #{ t: "ok" id: 2 transfer: #{ direction: "download" size: 3 chunk-bytes: 65536 } }) (assert-equal 1 (length sent)) ; initial credit (let ((payload (bytevector 0 0 0 0 9 8 7))) (resource-transfer-frame! r (transfer-frame-encode #x20 2 payload))) (assert-equal 1 (length sent)) ; all bytes were already credited (resource-transfer-frame! r (transfer-frame-encode #x21 2 (bytevector 0 0 0 1 0 0 0 3))) (assert-equal (bytevector 9 8 7) result) (assert-equal 262144 (dict-ref (car progress) peak-outstanding: -1)) (assert-equal 3 (dict-ref (car progress) peak-frame-bytes: -1)) (assert-equal 0 (resource-transfer-count r)))) (test "download credits only the remaining bytes and receives the final window without writing" (let ((r (make-resource-transfer-registry)) (sent '()) (result #f) (total 262147)) (resource-transfer-download! r 5 'image 'relay (lambda (frame) (set! sent (append sent (list frame)))) (lambda (bytes) (set! result bytes)) (lambda (reason) (error reason)) #f) (resource-transfer-control! r #{ t: "ok" id: 5 transfer: #{ direction: "download" size: total } }) (let loop ((seq 0)) (when (< seq 4) (resource-transfer-frame! r (transfer-frame-encode #x20 5 (bytevector-append (u32 seq) (make-bytevector 65536 seq)))) (assert-equal 2 (length sent)) (loop (+ seq 1)))) (assert-equal (transfer-frame-encode #x23 5 (u32 3)) (cadr sent)) (resource-transfer-frame! r (transfer-frame-encode #x20 5 (bytevector-append (u32 4) (bytevector 9 8 7)))) (resource-transfer-frame! r (transfer-frame-encode #x21 5 (bytevector-append (u32 5) (u32 total)))) (assert-equal 2 (length sent)) (assert-equal total (bytevector-length result)) (assert-equal 7 (bytevector-u8-ref result (- total 1))))) (test "owner teardown fails only that owner's transfers" (let ((r (make-resource-transfer-registry)) (failures 0)) (resource-transfer-download! r 3 'carrier-a 'relay (lambda (_) #f) (lambda (_) #f) (lambda (_) (set! failures (+ failures 1))) (lambda (_) #f)) (resource-transfer-download! r 4 'carrier-b 'direct (lambda (_) #f) (lambda (_) #f) (lambda (_) (set! failures (+ failures 1))) (lambda (_) #f)) (assert-equal 1 (resource-transfer-remove-owner! r 'carrier-a "lost")) (assert-equal 1 failures) (assert-equal 1 (resource-transfer-count r)))) )packages/slate/test/root-browser-gate.mjsmodified
const message = JSON.parse(event.data); if (message.method === 'Runtime.exceptionThrown') errors.push(message.params.exceptionDetails.exception?.description || message.params.exceptionDetails.text); if (message.method === 'Runtime.consoleAPICalled' && message.params.type === 'error') errors.push(message.params.args.map(arg => arg.description || arg.value || '').join(' ')); if (message.id && pending.has(message.id)) { const pair = pending.get(message.id); pending.delete(message.id); message.error ? pair.reject(new Error(message.error.message)) : pair.resolve(message.result); const session = attached.sessionId; await send('Runtime.enable', {}, session); await send('Page.enable', {}, session); // This gate owns runtime/root persistence, not service-worker lifecycle. // A fresh profile otherwise installs + claims the worker and reloads between // the ready observation and the first slateEval, racing the artifact check. await send('Page.addScriptToEvaluateOnNewDocument', { source: `if ('ServiceWorkerContainer' in globalThis) { Object.defineProperty(ServiceWorkerContainer.prototype, 'register', { configurable: true, value: () => Promise.reject(new Error('service worker disabled by root gate')) }); }` }, session); const forceFallback = process.env.SLATE_ROOT_GATE_BRANCH === 'fallback'; if (forceFallback) { await send('Page.addScriptToEvaluateOnNewDocument', { return result.result.value; }; for (let i = 0; i < 360; i++) { if (await evaluate(`document.getElementById('status')?.textContent === 'ready'`)) break; if (await evaluate(`document.getElementById('status')?.textContent === 'ready' && typeof window.slateEval === 'function'`)) break; if (i === 359) throw new Error(`timeout waiting for Slate boot; errors=${errors.join(' | ')}`); await sleep(100); } (source-persist (root->source browser: file: "/init.sgl") ${JSON.stringify(source)}))`); await sgl('(import (slate hooks))'); assert.equal(await sgl('(begin (import (slate pane)) (if (procedure? tree-find) "ready" "missing"))'), 'ready', 'native artifact must preserve imported pane bindings'); for (let i = 0; i < 120; i++) { if (await sgl('(if (= (length (hook-trace root-changed:)) 2) "ready" "waiting")') === 'ready') break; if (i === 119) throw new Error('timeout waiting for browser-root arrival'); await sleep(50); } const traceContext = `(dict-ref (car (reverse (hook-trace root-changed:))) context: #{})`; assert.equal(await sgl(`(keyword->string (dict-ref ${traceContext} occasion: #f))`), 'registered', 'boot must trace a registered: arrival'); assert.deepEqual(await evaluate(`[ localStorage.getItem('slate.project/catalog'), localStorage.getItem('slate.project/search-roots') ]`), ['[]', '[]'], 'project preferences must survive restart'); ]`), [null, null], 'clean-break boot must remove legacy project preference keys'); assert.deepEqual(errors, [], `artifact raised exceptions: ${errors.join(' | ')}`); console.log(`PASS root artifact (${forceFallback ? 'localStorage fallback' : 'OPFS'}): ` + 'single boot arrival and handler latch delivered');packages/slate/test/root-test.sglmodified
(test "rooted-path formats the parse's inverse" (assert-equal (rooted-path zerocool: "/etc") "zerocool:/etc"))(test "local remains readable but writes through the canonical local root" (register-root! local: #{ provider: local: label: "This computer" class: local: local?: #t }) (register-root! mybox: #{ provider: local: host: "opaque-local" label: "mybox" class: machine: local?: #t }) ;; Old saved/typed addresses still parse as the compatibility alias. (assert-equal (parse-rooted-path "local:/work") #{ root: local: path: "/work" }) ;; Every new spelling and every raw local source identity is canonical. (assert-equal mybox: (canonical-root-name local:)) (assert-equal "mybox:/work" (rooted-path local: "/work")) (assert-equal mybox: (source-root #{ kind: file: provider: local: address: "/work" })) (unregister-root! mybox:) (unregister-root! local:))(test "display: bare root bare; machine roots never collide; rootless falls back" (assert-equal (source-root-display-name (root->source alpha: file: "/a.txt")) "/a.txt")packages/slate/test/searchable-test.sglmodified
(let* ((v (a-folder)) (rows ((dict-ref (cap-of v) rows:) v))) (assert-equal 5 (vector-length rows)) (assert-equal "README.md" (vector-ref rows 0)) (assert-equal "modes" (vector-ref rows 3)))) (assert-equal "README.md" (vector-ref rows 2)) (assert-equal "modes" (vector-ref rows 0))))(test "goto: moves the point to the row" (let* ((v (a-folder)) (hits (search-matches rows "sgl"))) (assert-equal 2 (length hits)) (assert-equal "buffer.sgl" (dict-ref (car hits) label: "")) (assert-equal 1 (dict-ref (car hits) line: -1)) (assert-equal 3 (dict-ref (car hits) line: -1)) (assert-equal "search.sgl" (dict-ref (cadr hits) label: "")) (assert-equal 2 (dict-ref (cadr hits) line: -1)) (assert-equal 4 (dict-ref (cadr hits) line: -1)) ;; And the row index round-trips through goto: to the right entry. (let ((moved ((dict-ref (cap-of v) goto:) v (dict-ref (cadr hits) line: 0) 0))) (assert-equal "search.sgl" (dict-ref (folder-entry-at moved) name: "")))))packages/slate/test/selector-test.sglmodified
(assert-equal (selector-clamp -1 3) 0) (assert-equal (selector-clamp 0 0) 0) (assert-equal (selector-clamp 1 3) 1));; ---- full search before the render window ---------------------------------(test "a query finds a candidate beyond the first fifty source rows" ;; The UI caps only AFTER ranking. A command's declaration position never ;; makes it unsearchable; typing its distinguishing text promotes it into the ;; rendered window even when more than fifty unrelated commands precede it. (let* ((noise (let loop ((i 0) (acc '())) (if (= i 60) (reverse acc) (loop (+ i 1) (cons (cand (string-append "ordinary-command-" (number->string i))) acc))))) (target "uniquely-searchable-command") (ranked (rank "uniquely-searchable" (append noise (list (cand target)))))) (assert-equal ranked (list target))))packages/slate/test/state-test.sglmodified
(assert-equal (dict-ref next slate/echo: #f) "saved") (assert-equal (dict-ref next slate/echo-age: #f) 0) (assert-equal (dict-ref next slate/echo-fading?: #t) #f) (assert-equal (dict-ref next slate/echo-generation: #f) 1) (assert-equal (view-lines (find-view-by-name next "*messages*")) (list "boot" "saved")))) (assert-equal (dict-ref next slate/echo: #f) "C-x-") (assert-equal (dict-ref next slate/echo-age: #f) 0) (assert-equal (dict-ref next slate/echo-fading?: #t) #f) (assert-equal (dict-ref next slate/echo-generation: #f) 1) (assert-equal (view-lines (find-view-by-name next "*messages*")) (list "boot"))))packages/slate/test/term-diff-test.sglmodified
(string-append ESC "[38;2mx") (string-append ESC "P malicious dcs payload " ESC "\\ok") (string-append ESC "[999999999Ix") (string-append ESC "[?9999h" ESC "[<5m" ESC "(0ok") ;; DEC graphics are covered by sigil-vt and the browser gate; the historical ;; reference does not implement them. Keep this fixture on an unknown set. (string-append ESC "[?9999h" ESC "[<5m" ESC "(Xok") "aλbμc" "abcdefgh"))packages/slate/test/term-test.sglmodified
(let ((t (term* 10 2 ESC "[999999999I" "x"))) (check "CHT clamps" (cursor-of t) (list 0 (- 10 1))))(let ((t (term* 10 2 ESC "[?9999h" ESC "[<5m" ESC "(0" "ok")))(let ((t (term* 10 2 ESC "[?9999h" ESC "[<5m" ESC "(X" "ok"))) (check "unknown modes/charsets swallowed" (rows-of t) (list "ok" "")));; an escape split across chunk boundaries parses wholepackages/slate/test/terminal-compose-browser-gate.mjsadded
// Mobile terminal deck gate: real touch events and native text input against the shipped WASM.import assert from 'node:assert/strict';import fs from 'node:fs';import http from 'node:http';import os from 'node:os';import path from 'node:path';import { spawn } from 'node:child_process';const root = path.resolve(import.meta.dirname, '../../..');const webRoot = process.env.SLATE_TEST_WEB_ROOT || path.join(root, 'build/web');assert.ok(fs.existsSync(path.join(webRoot, 'slate.wasm')), 'build/web/slate.wasm is required');const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));const types = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.wasm': 'application/wasm', '.json': 'application/json', '.svg': 'image/svg+xml', '.woff2': 'font/woff2' };const server = http.createServer((req, res) => { const relative = req.url.split('?')[0] === '/' ? 'index.html' : req.url.split('?')[0].slice(1); const target = path.resolve(webRoot, relative); if (!target.startsWith(webRoot + path.sep) || !fs.existsSync(target)) { res.writeHead(404).end(); return; } res.setHeader('content-type', types[path.extname(target)] || 'application/octet-stream'); fs.createReadStream(target).pipe(res);});await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'slate-compose-chrome-'));const chrome = spawn('google-chrome', ['--headless=new', '--no-sandbox', '--disable-gpu', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'],{ stdio: ['ignore', 'ignore', 'pipe'] });const chromeExit = new Promise(resolve => chrome.once('exit', resolve));let socket;const errors = [];try { const activePort = path.join(profile, 'DevToolsActivePort'); for (let i = 0; i < 120 && !fs.existsSync(activePort); i++) await sleep(50); assert.ok(fs.existsSync(activePort), 'Chrome did not expose DevTools'); const [debugPort, browserPath] = fs.readFileSync(activePort, 'utf8').trim().split('\n'); socket = new WebSocket(`ws://127.0.0.1:${debugPort}${browserPath}`); await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; }); let nextId = 0; const pending = new Map(); socket.onmessage = event => { const message = JSON.parse(event.data); if (message.method === 'Runtime.exceptionThrown') errors.push(message.params.exceptionDetails.exception?.description || message.params.exceptionDetails.text); if (message.id && pending.has(message.id)) { const pair = pending.get(message.id); pending.delete(message.id); message.error ? pair.reject(new Error(message.error.message)) : pair.resolve(message.result); } }; const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => { const id = ++nextId; const timer = setTimeout(() => { pending.delete(id); reject(new Error('CDP timeout: ' + method)); }, 15000); pending.set(id, {resolve: value => { clearTimeout(timer); resolve(value); }, reject: error => { clearTimeout(timer); reject(error); }}); socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); }); const target = await send('Target.createTarget', { url: 'about:blank' }); const attached = await send('Target.attachToTarget', { targetId: target.targetId, flatten: true }); const session = attached.sessionId; await send('Runtime.enable', {}, session); await send('Emulation.setDeviceMetricsOverride', {width:390,height:844,deviceScaleFactor:1,mobile:true}, session); await send('Emulation.setTouchEmulationEnabled', {enabled:true}, session); await send('Page.navigate', { url: process.env.SLATE_TEST_URL || `http://127.0.0.1:${server.address().port}/?rbdebug&prediction-gate` }, session); const evaluate = async expression => { const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, session); if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text); return result.result.value; }; async function waitFor(expression, label) { for (let i = 0; i < 360; i++) { if (await evaluate(expression)) return; await sleep(100); } const diagnostic = await evaluate(`({status:document.getElementById('status')?.textContent, body:document.body.innerText.slice(-2000)})`); throw new Error(`timeout waiting for ${label}; errors=${errors.join(' | ')}; page=${JSON.stringify(diagnostic)}`); } const dispatch = event => evaluate(`window.slateEval('(slate-dispatch "${event}")')`); await waitFor(`document.getElementById('status')?.textContent === 'ready'`, 'Slate boot'); console.log('PASS browser boot'); const sgl = async code => {const value = await evaluate(`window.slateEval(${JSON.stringify(code)})`); if (/^(Error|error):/.test(value)) throw new Error(value); if (code.includes('(slate-refresh)')) await evaluate('window.slateRefresh()'); return value;}; await waitFor(`window.slateEval('(slate-preferences-settled?)') === '1'`, 'preference restore'); await waitFor(`!document.getElementById('splash')`, 'interactive app'); await evaluate('document.fonts.ready.then(() => true)'); await sgl('(import (slate provider) (slate modes terminal) (slate extend))'); await sgl(`(begin (define compose-context #f) (define compose-writes "") (register-provider! compose-probe: #{ session-open: (lambda (source ctx) (set! compose-context ctx) #{ write: (lambda (bytes) (set! compose-writes (string-append compose-writes bytes))) }) }) (open-view! (make-terminal-view "compose-one" #{kind: pty: provider: compose-probe:})) ((dict-ref compose-context emit:) "$ ") (slate-refresh))`); await waitFor(`document.querySelector('.accessory-key-deck')?.getAttribute('data-compose-target') === 'compose-one'`, 'terminal deck'); const field = `document.querySelector('.terminal-compose-input')`; const alt = '.terminal-key-row [data-slate-click="sticky-mod:alt"]'; const ctrl = '.terminal-key-row [data-slate-click="sticky-mod:ctrl"]'; async function touch(selector, dx = 0) { const pos = await evaluate(`(() => { const r=document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect(); return {x:r.x+r.width/2,y:r.y+r.height/2}; })()`); assert.ok(pos.y > 0 && pos.y < 844, 'touch target is visible: ' + selector); await send('Input.dispatchTouchEvent', {type:'touchStart', touchPoints:[pos]}, session); if (dx) for(let i=1;i<=5;i++) await send('Input.dispatchTouchEvent', {type:'touchMove', touchPoints:[{x:pos.x+dx*i/5,y:pos.y}]}, session); await send('Input.dispatchTouchEvent', {type:'touchEnd', touchPoints:[]}, session); await sleep(50); } async function openCompose() { const button = '[data-slate-host-action="terminal-compose:open"]'; if (!await evaluate(`!!document.querySelector('${button}')`)) await touch('[data-slate-click="acc-cat:terminal"]'); await touch(button); assert.equal(await evaluate('document.activeElement.classList.contains("terminal-compose-input")'),true,'Compose Input focuses the native editor'); } async function enter() { await send('Input.dispatchKeyEvent', {type:'keyDown',key:'Enter',code:'Enter',windowsVirtualKeyCode:13}, session); await send('Input.dispatchKeyEvent', {type:'keyUp',key:'Enter',code:'Enter',windowsVirtualKeyCode:13}, session); } const writes = () => sgl('compose-writes'); const clear = () => sgl('(set! compose-writes "")'); await evaluate(`window.slateFocusTerminalKeys(); window.deckFocused=document.activeElement; window.deckBlur=0; window.deckFocused.addEventListener('blur',()=>window.deckBlur++);`); await touch(alt); assert.equal(await evaluate('document.activeElement === window.deckFocused'), true); assert.equal(await evaluate('window.deckBlur'), 0); assert.equal(await evaluate(`document.querySelector(${JSON.stringify(alt)}).classList.contains('once')`), true); await send('Input.dispatchKeyEvent', {type:'keyDown',key:'z',code:'KeyZ',text:'z'},session); await send('Input.dispatchKeyEvent', {type:'keyUp',key:'z',code:'KeyZ'},session); assert.equal(await writes(), '\x1bz', 'Alt applies once to the following live key'); assert.equal(await evaluate(`document.querySelector(${JSON.stringify(alt)}).classList.contains('off')`),true); await clear(); await touch(ctrl); assert.equal(await evaluate('document.activeElement === window.deckFocused && window.deckBlur === 0'),true); await touch(ctrl); await touch(ctrl); // once -> locked -> off console.log('PASS touch Alt/Ctrl retain native focus and modifiers still route'); await touch(alt,-65); assert.equal(await evaluate(`document.activeElement === ${field}`),true, 'swipe exposes and focuses composer'); assert.equal(await evaluate(`document.querySelector(${JSON.stringify(alt)}).classList.contains('off')`),true, 'swipe does not arm Alt'); const draft = 'Hello café — typing longer text'; await send('Input.insertText', {text:draft},session); assert.equal(await writes(),'','draft is local'); await evaluate(`${field}.setSelectionRange(6,10)`); await sgl('((dict-ref compose-context emit:) "server output")'); await sgl('(slate-refresh)'); assert.deepEqual(await evaluate(`({text:${field}.value, start:${field}.selectionStart,end:${field}.selectionEnd,focused:document.activeElement===${field}})`),{text:draft,start:6,end:10,focused:true}); await enter(); assert.equal(await writes(),draft,'Enter sends text only once, with no trailing CR'); assert.equal(await evaluate(`${field}.value`),''); console.log('PASS local Unicode draft, output/rerender preserve selection, Enter sends text only'); await clear(); await send('Input.insertText',{text:'IME draft'},session); await evaluate(`(() => {const f=${field}; f.dispatchEvent(new CompositionEvent('compositionstart',{bubbles:true})); f.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',isComposing:true,bubbles:true,cancelable:true})); f.dispatchEvent(new CompositionEvent('compositionend',{data:'draft',bubbles:true})); f.dispatchEvent(new InputEvent('beforeinput',{inputType:'insertLineBreak',bubbles:true,cancelable:true})); f.dispatchEvent(new KeyboardEvent('keyup',{key:'Enter',bubbles:true})); })()`); assert.equal(await writes(),'','IME confirmation is not submission'); await evaluate(`${field}.dispatchEvent(new InputEvent('beforeinput',{inputType:'insertLineBreak',bubbles:true,cancelable:true}))`); assert.equal(await writes(),'IME draft','soft keyboard Enter submits text only'); await clear(); await send('Input.insertText',{text:'saved for one'},session); await touch('.terminal-compose-back',65); assert.equal(await evaluate('document.activeElement === window.deckFocused'),true); await openCompose(); assert.equal(await evaluate(`${field}.value`),'saved for one'); await sgl(`(begin (define compose-one-context compose-context) (open-view! (make-terminal-view "compose-two" #{kind: pty: provider: compose-probe:})) (slate-refresh))`); await openCompose(); assert.equal(await evaluate(`${field}.value`),'','second terminal starts with its own draft'); await send('Input.insertText',{text:'saved for two'},session); assert.equal(await sgl('(slate-term-compose "compose-one" "wrong target")'),''); assert.equal(await writes(),'','stale target cannot receive another draft'); await sgl('(begin (open-view! (make-terminal-view "compose-one" #{kind: pty: provider: compose-probe:})) (slate-refresh))'); assert.equal(await evaluate(`${field}.value`),'saved for one'); await evaluate(`${field}.focus()`); await sgl('((dict-ref compose-one-context emit:) (string-append (string (integer->char 27)) "[?2004h"))'); await touch('.terminal-compose-send'); assert.equal(await writes(),'\x1b[200~saved for one\x1b[201~','bracketed paste adds no Enter'); await clear(); await send('Input.insertText',{text:'retain when disconnected'},session); await sgl('((dict-ref compose-one-context lost:) 1234)'); await enter(); assert.equal(await writes(),''); assert.equal(await evaluate(`${field}.value`),'retain when disconnected'); assert.match(await evaluate(`document.querySelector('.terminal-compose-status').textContent`),/Not sent/); console.log('PASS IME, soft Enter, draft switching, stale targets, bracketed paste, disconnected draft retention'); if (process.env.SLATE_TEST_SCREENSHOT_DIR) { fs.mkdirSync(process.env.SLATE_TEST_SCREENSHOT_DIR,{recursive:true}); const shot=await send('Page.captureScreenshot',{format:'png'},session); fs.writeFileSync(path.join(process.env.SLATE_TEST_SCREENSHOT_DIR,'terminal-compose.png'),Buffer.from(shot.data,'base64')); } await sgl('(slate-dispatch "scratchpad")'); await evaluate('window.slateRefresh()'); await waitFor(`document.querySelector('.pane.focused [contenteditable]:not([contenteditable="false"])')`, 'rendered editor'); await evaluate(`(() => { const editor=document.querySelector('.pane.focused [contenteditable]:not([contenteditable="false"])'); editor.focus(); window.editorFocused=editor; window.editorBlur=0; editor.addEventListener('blur',()=>window.editorBlur++); const selection=getSelection(); window.editorAnchor=selection.anchorNode; window.editorOffset=selection.anchorOffset; })()`); await touch(alt); assert.equal(await evaluate(`document.activeElement===window.editorFocused && window.editorBlur===0 && getSelection().anchorNode===window.editorAnchor && getSelection().anchorOffset===window.editorOffset`),true,'Alt preserves editor focus and caret'); await touch(ctrl); assert.equal(await evaluate('document.activeElement===window.editorFocused && window.editorBlur===0'),true); console.log('PASS Alt/Ctrl retain rendered editor focus and caret'); assert.deepEqual(errors,[]);} finally { if (socket) socket.close(); chrome.kill('SIGTERM'); await Promise.race([chromeExit, sleep(2000)]); await new Promise(resolve => server.close(resolve)); try { fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch (_) { /* Chrome helpers may finish touching the disposable profile late. */ }}packages/slate/test/terminal-copy-browser-gate.mjsadded
// Mobile terminal deck gate: real touch events and native text input against the shipped WASM.import assert from 'node:assert/strict';import fs from 'node:fs';import http from 'node:http';import os from 'node:os';import path from 'node:path';import { spawn } from 'node:child_process';const root = path.resolve(import.meta.dirname, '../../..');const webRoot = process.env.SLATE_TEST_WEB_ROOT || path.join(root, 'build/web');assert.ok(fs.existsSync(path.join(webRoot, 'slate.wasm')), 'build/web/slate.wasm is required');const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));const types = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.wasm': 'application/wasm', '.json': 'application/json', '.svg': 'image/svg+xml', '.woff2': 'font/woff2' };const server = http.createServer((req, res) => { const relative = req.url.split('?')[0] === '/' ? 'index.html' : req.url.split('?')[0].slice(1); const target = path.resolve(webRoot, relative); if (!target.startsWith(webRoot + path.sep) || !fs.existsSync(target)) { res.writeHead(404).end(); return; } res.setHeader('content-type', types[path.extname(target)] || 'application/octet-stream'); fs.createReadStream(target).pipe(res);});await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'slate-compose-chrome-'));const chrome = spawn('google-chrome', ['--headless=new', '--no-sandbox', '--disable-gpu', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'],{ stdio: ['ignore', 'ignore', 'pipe'] });const chromeExit = new Promise(resolve => chrome.once('exit', resolve));let socket;const errors = [];try { const activePort = path.join(profile, 'DevToolsActivePort'); for (let i = 0; i < 120 && !fs.existsSync(activePort); i++) await sleep(50); assert.ok(fs.existsSync(activePort), 'Chrome did not expose DevTools'); const [debugPort, browserPath] = fs.readFileSync(activePort, 'utf8').trim().split('\n'); socket = new WebSocket(`ws://127.0.0.1:${debugPort}${browserPath}`); await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; }); let nextId = 0; const pending = new Map(); socket.onmessage = event => { const message = JSON.parse(event.data); if (message.method === 'Runtime.exceptionThrown') errors.push(message.params.exceptionDetails.exception?.description || message.params.exceptionDetails.text); if (message.id && pending.has(message.id)) { const pair = pending.get(message.id); pending.delete(message.id); message.error ? pair.reject(new Error(message.error.message)) : pair.resolve(message.result); } }; const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => { const id = ++nextId; const timer = setTimeout(() => { pending.delete(id); reject(new Error('CDP timeout: ' + method)); }, 15000); pending.set(id, {resolve: value => { clearTimeout(timer); resolve(value); }, reject: error => { clearTimeout(timer); reject(error); }}); socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); }); const target = await send('Target.createTarget', { url: 'about:blank' }); const attached = await send('Target.attachToTarget', { targetId: target.targetId, flatten: true }); const session = attached.sessionId; await send('Runtime.enable', {}, session); await send('Emulation.setDeviceMetricsOverride', {width:390,height:844,deviceScaleFactor:1,mobile:true}, session); await send('Emulation.setTouchEmulationEnabled', {enabled:true}, session); await send('Page.navigate', { url: process.env.SLATE_TEST_URL || `http://127.0.0.1:${server.address().port}/?rbdebug&prediction-gate` }, session); const evaluate = async expression => { const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, session); if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text); return result.result.value; }; async function waitFor(expression, label) { for (let i = 0; i < 360; i++) { if (await evaluate(expression)) return; await sleep(100); } const diagnostic = await evaluate(`({status:document.getElementById('status')?.textContent, body:document.body.innerText.slice(-2000)})`); throw new Error(`timeout waiting for ${label}; errors=${errors.join(' | ')}; page=${JSON.stringify(diagnostic)}`); } const dispatch = event => evaluate(`window.slateEval('(slate-dispatch "${event}")')`); await waitFor(`document.getElementById('status')?.textContent === 'ready'`, 'Slate boot'); console.log('PASS browser boot'); const sgl = async code => {const value = await evaluate(`window.slateEval(${JSON.stringify(code)})`); if (/^(Error|error):/.test(value)) throw new Error(value); if (code.includes('(slate-refresh)')) await evaluate('window.slateRefresh()'); return value;}; await waitFor(`window.slateEval('(slate-preferences-settled?)') === '1'`, 'preference restore'); await waitFor(`!document.getElementById('splash')`, 'interactive app'); await evaluate('document.fonts.ready.then(() => true)'); await sgl('(import (slate provider) (slate modes terminal) (slate extend))'); await sgl(`(begin (define compose-context #f) (define compose-writes "") (register-provider! compose-probe: #{ session-open: (lambda (source ctx) (set! compose-context ctx) #{ write: (lambda (bytes) (set! compose-writes (string-append compose-writes bytes))) }) }) (open-view! (make-terminal-view "compose-one" #{kind: pty: provider: compose-probe:})) ((dict-ref compose-context emit:) "$ ") (slate-refresh))`); await waitFor(`document.querySelector('.accessory-key-deck')?.getAttribute('data-compose-target') === 'compose-one'`, 'terminal deck'); const field = `document.querySelector('.terminal-compose-input')`; const alt = '.terminal-key-row [data-slate-click="sticky-mod:alt"]'; const ctrl = '.terminal-key-row [data-slate-click="sticky-mod:ctrl"]'; async function touch(selector, dx = 0) { const pos = await evaluate(`(() => { const r=document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect(); return {x:r.x+r.width/2,y:r.y+r.height/2}; })()`); assert.ok(pos.y > 0 && pos.y < 844, 'touch target is visible: ' + selector); await send('Input.dispatchTouchEvent', {type:'touchStart', touchPoints:[pos]}, session); if (dx) for(let i=1;i<=5;i++) await send('Input.dispatchTouchEvent', {type:'touchMove', touchPoints:[{x:pos.x+dx*i/5,y:pos.y}]}, session); await send('Input.dispatchTouchEvent', {type:'touchEnd', touchPoints:[]}, session); await sleep(50); } async function openCompose() { const button = '[data-slate-host-action="terminal-compose:open"]'; if (!await evaluate(`!!document.querySelector('${button}')`)) await touch('[data-slate-click="acc-cat:terminal"]'); await touch(button); assert.equal(await evaluate('document.activeElement.classList.contains("terminal-compose-input")'),true,'Compose Input focuses the native editor'); } async function enter() { await send('Input.dispatchKeyEvent', {type:'keyDown',key:'Enter',code:'Enter',windowsVirtualKeyCode:13}, session); await send('Input.dispatchKeyEvent', {type:'keyUp',key:'Enter',code:'Enter',windowsVirtualKeyCode:13}, session); } const writes = () => sgl('compose-writes'); const clear = () => sgl('(set! compose-writes "")'); const copyButton = action => `.accessory-bar [data-terminal-copy="${action}"]`; const copyMenu = '[data-slate-click="acc-cat:terminal-copy"]'; async function hardware(key, modifiers = 0) { await send('Input.dispatchKeyEvent', {type:'keyDown',key,modifiers},session); await send('Input.dispatchKeyEvent', {type:'keyUp',key,modifiers},session); } async function output(text) { const literal=JSON.stringify(text).replace(/\\u([0-9a-f]{4})/g,'\\x$1;'); await sgl(`((dict-ref compose-context emit:) ${literal})`); await evaluate('window.slateTerm.flush()'); } // Exercise the linked native VT parser through real terminal rendering. // tmux may send ASCII q in DEC graphics mode for a Unicode horizontal line. await output('\x1bc\x1b(0lqqk\x1b(Bqq\r\n\x1b)0q\x0eqx\x0fq'); await waitFor(`document.querySelector('.term-content').innerText.includes('┌──┐qq')`, 'G0 graphics rendering'); assert.match(await evaluate(`document.querySelector('.term-content').innerText`), /q─│q/, 'G1 SO/SI renders graphics and returns to ASCII'); await output('\r\n\x1b('); await output('0q─é\x1b(Bq'); await sgl('(slate-refresh)'); await waitFor(`document.querySelector('.term-content').innerText.includes('──éq')`, 'split charset and Unicode after refresh'); await output('\x1bc$ '); console.log('PASS native WASM DEC graphics, charset switching, split output and Unicode rendering'); await openCompose(); assert.equal(await evaluate(`document.querySelector('.terminal-compose-send').disabled`),true); for (let i=1;i<=6;i++) { await send('Input.insertText',{text:`draft ${i} café`},session); await enter(); } assert.equal(await writes(),Array.from({length:6},(_,i)=>`draft ${i+1} café`).join('')); assert.equal(await evaluate(`document.querySelector('.terminal-compose-send').getAttribute('aria-label')`),'Text history'); await touch('.terminal-compose-send'); assert.deepEqual(await evaluate(`Array.from(document.querySelectorAll('[data-terminal-history]'),b=>b.textContent)`),[6,5,4,3,2].map(n=>`draft ${n} café`)); await evaluate('window.firstHistoryEntry=document.querySelector("[data-terminal-history]")'); await evaluate('window.slateRefresh()'); assert.equal(await evaluate('document.querySelector("[data-terminal-history]")===window.firstHistoryEntry'),true,'unchanged history preserves its existing DOM entries'); assert.deepEqual(await evaluate(`Array.from(document.querySelectorAll('[data-terminal-history]'),b=>b.textContent)`),[6,5,4,3,2].map(n=>`draft ${n} café`),'an open history list survives refresh without rebuilding its contents'); await touch('[data-terminal-history="2"]'); assert.equal(await evaluate(`${field}.value`),'draft 4 café'); await clear(); assert.equal(await writes(),''); await sgl('(slate-refresh)'); assert.equal(await evaluate(`${field}.value`),'draft 4 café'); await sgl(`(begin (define history-one-context compose-context) (open-view! (make-terminal-view "history-two" #{kind: pty: provider: compose-probe:})) (slate-refresh))`); await openCompose(); assert.equal(await evaluate(`document.querySelector('.terminal-compose-send').disabled`),true,'history belongs to its terminal'); await sgl('(begin (open-view! (make-terminal-view "compose-one" #{kind: pty: provider: compose-probe:})) (set! compose-context history-one-context) (slate-refresh))'); assert.equal(await evaluate(`${field}.value`),'draft 4 café'); await touch('.terminal-compose-back'); console.log('PASS five-entry ephemeral history, clock, recall without sending, rerender and terminal isolation'); await evaluate(`window.clipboardWrites=[]; window.failClipboard=false; window.realClipboard=navigator.clipboard; Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{if(window.failClipboard)throw Error('test denial');window.clipboardWrites.push(text);}}}); window.realExecCommand=document.execCommand.bind(document); document.execCommand=()=>false;`); assert.equal(await evaluate('document.querySelectorAll(".terminal-compose-toggle").length'),0,'no pencil or reserved column'); assert.deepEqual(await evaluate(`Array.from(document.querySelectorAll('[data-accessory-category="terminal"] > .acc-btn'), el=>el.textContent)`),['Compose Input','C-c','C-d','C-r','Close'],'Term contains only composition, control chords and Close'); const targets = () => evaluate(`Array.from(document.querySelectorAll('.terminal-key-row .term-key, .acc-kbd-chip'), el => {const r=el.getBoundingClientRect(); return {x:r.x,y:r.y,width:r.width,height:r.height};})`); const terminalTargets = await targets(); await sgl('(slate-dispatch "scratchpad")'); await evaluate('window.slateRefresh()'); assert.equal(await evaluate(`!!document.querySelector('${copyMenu}')`),false,'Copy category is terminal-only'); assert.deepEqual(await targets(),terminalTargets,'keyboard, Ctrl/Alt and all key targets stay fixed across views'); await sgl('(begin (open-view! (make-terminal-view "compose-one" #{kind: pty: provider: compose-probe:})) (set! compose-context history-one-context) (slate-refresh))'); await evaluate('window.slateFocusTerminalKeys()'); await touch(copyMenu); assert.equal(await evaluate('window.slateCopy.report().length'),0,'opening Copy does not freeze'); assert.equal(await evaluate('document.activeElement.id'),'slate-key-capture','opening Copy retains keyboard focus'); assert.deepEqual(await targets(),terminalTargets,'opening the menu leaves key targets fixed'); assert.equal(await evaluate('document.querySelectorAll(".view.terminal > .terminal-copy-tools").length'),0,'no dedicated terminal copy row'); assert.deepEqual(await evaluate(`(() => { window.slateCopy.sync(); const observer=new MutationObserver(()=>{}); observer.observe(document.body,{attributes:true,childList:true,subtree:true}); for(let i=0;i<5;i++)window.slateCopy.sync(); const changes=observer.takeRecords().map(m=>({type:m.type,attribute:m.attributeName,target:m.target.className})); observer.disconnect(); return changes; })()`),[],'unchanged copy controls never retrigger the app mutation observer'); await clear(); await send('Input.insertText',{text:'x'},session); assert.equal(await writes(),'x','live input still works with Copy menu open'); await clear(); console.log('PASS terminal-only Copy menu, explicit entry, keyboard focus and fixed key targets'); const lines=Array.from({length:150},(_,i)=>i===40?'row 040 <script>safe</script>':`row ${String(i).padStart(3,'0')} café`); await output('\x1b[2J\x1b[H'+lines.join('\r\n')); await touch(copyButton('start')); assert.equal(await evaluate('window.slateCopy.report().length'),1); const frozen=await evaluate(`document.querySelector('.term-content').innerText`); await output(Array.from({length:1200},(_,i)=>'\r\nlater output '+i).join('')+'\r\nnew live output'); await sgl('(slate-refresh)'); assert.equal(await evaluate(`document.querySelector('.term-content').innerText`),frozen,'output and full render cannot change the frozen rows'); await hardware('x'); assert.equal(await writes(),'','copy mode swallows live typing'); assert.equal(await sgl('(slate-term-compose "compose-one" "not sent")'),''); await hardware('Home',2); await touch(ctrl); await send('Input.insertText',{text:' '},session); assert.deepEqual((await evaluate('window.slateCopy.report()'))[0].mark,{row:0,col:0}); await hardware('End',2); assert.equal((await evaluate('window.slateCopy.report()'))[0].text,lines.join('\n'),'mark spans retained history beyond viewport'); const beforeClose = await evaluate('window.slateCopy.report()'); const selectionOnly = rows => rows.map(({top, ...selection}) => selection); await touch(copyMenu); assert.equal(await evaluate('document.querySelector(".pane.focused .terminal-copy-label").hidden'),false); assert.equal(await evaluate('document.querySelector(".accessory-bar").innerText.includes("Tap a start")'),false,'copy help disappears when menu collapses'); assert.equal(await evaluate('document.querySelectorAll(".acc-cats .terminal-copy-status, .acc-cats .acc-host-status").length'),0,'copy status never migrates into the category row'); if (process.env.SLATE_TEST_SCREENSHOT_DIR) { fs.mkdirSync(process.env.SLATE_TEST_SCREENSHOT_DIR,{recursive:true}); const shot=await send('Page.captureScreenshot',{format:'png'},session); fs.writeFileSync(path.join(process.env.SLATE_TEST_SCREENSHOT_DIR,'copy-collapsed.png'),Buffer.from(shot.data,'base64')); } await touch(copyMenu); assert.deepEqual(selectionOnly(await evaluate('window.slateCopy.report()')),selectionOnly(beforeClose),'closing and reopening Copy preserves the selection'); assert.match(await evaluate(`document.querySelector('[data-accessory-category="terminal-copy"] .acc-host-status').textContent`),/Tap a start/,'help returns inside the reopened menu'); await touch('[data-slate-click="acc-cat:terminal"]'); assert.equal(await evaluate('!!document.querySelector(".acc-actions.terminal-copy-tools")'),false); await touch(copyMenu); // Different action rows change viewport height and may clamp its top row. // The frozen text and selection endpoints must remain unchanged. assert.deepEqual(selectionOnly(await evaluate('window.slateCopy.report()')),selectionOnly(beforeClose),'switching categories preserves the selection'); await evaluate('window.failClipboard=true'); await touch(copyButton('copy')); assert.equal(await evaluate('window.slateCopy.report().length'),1,'failed copy retains frozen selection'); assert.match(await evaluate(`document.querySelector('.terminal-copy-status').textContent`),/Copy failed/); await evaluate('window.failClipboard=false'); await hardware('w',1); await waitFor('window.slateCopy.report().length === 0','successful copy exit'); assert.deepEqual(await evaluate('window.clipboardWrites'),[lines.join('\n')]); await evaluate('window.slateTerm.flush()'); assert.match(await evaluate(`document.querySelector('.term-content').innerText`),/new live output/); console.log('PASS frozen scrollback, local input isolation, Emacs mark/motion, exact Unicode text, failed-copy retention and live return'); // Touch selection: tap -> Mark -> scroll -> tap another endpoint. await touch(copyButton('start')); const rect=await evaluate(`(() => {const el=document.querySelector('.term-content'), r=el.getBoundingClientRect(),s=getComputedStyle(el);return {x:r.x+parseFloat(s.paddingLeft)+20,y:r.y+parseFloat(s.paddingTop)+8};})()`); const tapAt=async pos=>{ await send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[pos]},session); await send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]},session); }; await tapAt(rect); await touch(copyButton('mark')); const mark=(await evaluate('window.slateCopy.report()'))[0].mark; const dragStart={x:rect.x+60,y:rect.y+60}; await send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[dragStart]},session); for(let i=1;i<=8;i++)await send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{x:dragStart.x,y:dragStart.y+i*25}]},session); await send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]},session); await tapAt(rect); const selected=(await evaluate('window.slateCopy.report()'))[0]; assert.deepEqual(selected.mark,mark); assert.ok(selected.point.row<mark.row,'scroll then tap extends region into older rows'); assert.ok(selected.text.includes('\n')); assert.equal(await writes(),'','touch selection never writes to the PTY'); if(process.env.SLATE_TEST_SCREENSHOT_DIR){ fs.mkdirSync(process.env.SLATE_TEST_SCREENSHOT_DIR,{recursive:true}); const shot=await send('Page.captureScreenshot',{format:'png'},session); fs.writeFileSync(path.join(process.env.SLATE_TEST_SCREENSHOT_DIR,'terminal-copy.png'),Buffer.from(shot.data,'base64')); } await touch(copyButton('cancel')); // A full-screen app exposes its current screen, not unrelated main-screen history. await output('\x1b[?1049h\x1b[2J\x1b[Hfull screen app\x1b[?1000h'); await touch(copyButton('start')); await hardware('Home',2); await hardware(' ',2); await hardware('End',2); const alternate=(await evaluate('window.slateCopy.report()'))[0].text; assert.ok(alternate.startsWith('full screen app')); assert.ok(!alternate.includes('row 000')); await hardware('g',2); assert.equal(await writes(),''); await output('\x1b[?1049l'); await touch(copyMenu); // Received-text badge is visible even while the menu is closed. const tmuxFixture=process.env.SLATE_TEST_OSC52_FILE ? JSON.parse(fs.readFileSync(process.env.SLATE_TEST_OSC52_FILE,'utf8')) : null; const receivedText=tmuxFixture?.text || 'tmux clipboard café\nsecond line'; await output(tmuxFixture?.sequence || ('\x1b]52;c;'+Buffer.from(receivedText).toString('base64')+'\x07')); await evaluate('window.slateRefresh()'); assert.equal(await evaluate(`document.querySelector('${copyMenu}').classList.contains('copy-received')`),true); await touch(copyMenu); assert.equal(await evaluate(`document.querySelector('${copyButton('received')}').hidden`),false); await evaluate('window.failClipboard=true'); await touch(copyButton('received')); assert.equal(await sgl('(slate-tmux-clip-staged)'),receivedText); await evaluate('window.failClipboard=false'); await touch(copyButton('received')); await waitFor(`window.slateEval('(slate-tmux-clip-staged)') === ''`,'received copy acknowledgment'); assert.equal((await evaluate('window.clipboardWrites')).at(-1),receivedText); assert.equal(await evaluate(`document.querySelector('${copyMenu}').classList.contains('copy-received')`),false); console.log('PASS tap/mark/scroll/tap, alternate-screen isolation, OSC 52 mobile take and failure retention'); // Verify one real clipboard operation in the browser as well as the fault seam. const origin = await evaluate('location.origin'); await send('Browser.grantPermissions',{origin,permissions:['clipboardReadWrite','clipboardSanitizedWrite']}); await evaluate(`Object.defineProperty(navigator,'clipboard',{configurable:true,value:window.realClipboard}); document.execCommand=window.realExecCommand;`); await output(tmuxFixture?.sequence || ('\x1b]52;c;'+Buffer.from(receivedText).toString('base64')+'\x07')); await evaluate('window.slateRefresh()'); await touch(copyButton('received')); await waitFor(`window.slateEval('(slate-tmux-clip-staged)') === ''`, 'real browser clipboard write'); assert.equal(await evaluate('navigator.clipboard.readText()'),receivedText); console.log('PASS real browser clipboard receives the exact terminal clipboard text'); // Copy controls must focus their own pane before subsequent keyboard input. await sgl('(begin (slate-dispatch "split-down") (open-view! (make-terminal-view "history-two" #{kind: pty: provider: compose-probe:})) (slate-refresh))'); assert.equal(await evaluate('document.querySelectorAll(".pane").length'),2); // Desktop keeps compact controls in the existing header. await send('Emulation.setDeviceMetricsOverride',{width:1100,height:844,deviceScaleFactor:1,mobile:false},session); const otherStart = '.pane:not(.focused) .terminal-copy-header-tools [data-terminal-copy="start"]'; const otherId = await evaluate(`document.querySelector('${otherStart}').closest('.pane').dataset.paneId`); await touch(otherStart); assert.equal(await evaluate('document.querySelector(".pane.focused").dataset.paneId'),otherId); await clear(); await hardware('x'); assert.equal(await writes(),'','copying in another pane keeps input local'); await hardware('Home',2); await hardware(' ',2); await hardware('End',2); assert.ok((await evaluate('window.slateCopy.report()'))[0].text.length>0); await hardware('g',2); assert.equal(await evaluate('window.slateCopy.report().length'),0); console.log('PASS copy controls focus their split pane and keep subsequent input local'); assert.deepEqual(errors,[]);} finally { if (socket) socket.close(); chrome.kill('SIGTERM'); await Promise.race([chromeExit, sleep(2000)]); await new Promise(resolve => server.close(resolve)); try { fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch (_) { /* Chrome helpers may finish touching the disposable profile late. */ }}packages/slate/test/terminal-copy-test.mjsadded
import assert from 'node:assert/strict';import { TerminalCopySelection } from '../assets/terminal-copy.mjs';const make = () => new TerminalCopySelection({ html: [], rows: ['alpha βeta', 'middle', 'last 😀 line'], cols: 20, top: 0, point: {row: 0,col: 0} });const model = make(); model.height = 2;model.key('C-Space'); model.key('M->');assert.equal(model.text(), 'alpha βeta\nmiddle\nlast 😀 line');assert.equal(model.top, 1);model.key('M-<'); model.key('M-f');assert.deepEqual(model.point, {row:0,col:5});model.key('M-f'); assert.deepEqual(model.point, {row:0,col:10});model.key('M-b'); assert.deepEqual(model.point, {row:0,col:6});model.setPoint(2, 6); model.setMark(); model.setPoint(0, 6);assert.equal(model.text(), 'βeta\nmiddle\nlast 😀');model.scroll(-1000); assert.equal(model.top,0);assert.equal(model.text(), 'βeta\nmiddle\nlast 😀', 'scrolling preserves a reversed selection');model.scroll(1000); assert.equal(model.top,1);model.setPoint(0,0); model.key('C-b'); assert.deepEqual(model.point,{row:0,col:0});model.key('M->'); model.key('C-f'); assert.deepEqual(model.point,{row:2,col:11});console.log('PASS frozen region: multi-page selection, reversed endpoints, Unicode, Emacs word motion, bounds');packages/slate/test/terminal-geometry-browser-gate.mjsadded
// Mobile resize settling and touch sensitivity against the shipped WASM provider.import assert from 'node:assert/strict';import fs from 'node:fs';import http from 'node:http';import os from 'node:os';import path from 'node:path';import { spawn } from 'node:child_process';const root = path.resolve(import.meta.dirname, '../../..');const webRoot = process.env.SLATE_TEST_WEB_ROOT || path.join(root, 'build/web');assert.ok(fs.existsSync(path.join(webRoot, 'slate.wasm')), 'build/web/slate.wasm is required');const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));const types = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.wasm': 'application/wasm', '.json': 'application/json', '.svg': 'image/svg+xml', '.woff2': 'font/woff2' };const server = http.createServer((req, res) => { const relative = req.url.split('?')[0] === '/' ? 'index.html' : req.url.split('?')[0].slice(1); const target = path.resolve(webRoot, relative); if (!target.startsWith(webRoot + path.sep) || !fs.existsSync(target)) { res.writeHead(404).end(); return; } res.setHeader('content-type', types[path.extname(target)] || 'application/octet-stream'); fs.createReadStream(target).pipe(res);});await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'slate-compose-chrome-'));const chrome = spawn('google-chrome', ['--headless=new', '--no-sandbox', '--disable-gpu', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'],{ stdio: ['ignore', 'ignore', 'pipe'] });const chromeExit = new Promise(resolve => chrome.once('exit', resolve));let socket;const errors = [];try { const activePort = path.join(profile, 'DevToolsActivePort'); for (let i = 0; i < 120 && !fs.existsSync(activePort); i++) await sleep(50); assert.ok(fs.existsSync(activePort), 'Chrome did not expose DevTools'); const [debugPort, browserPath] = fs.readFileSync(activePort, 'utf8').trim().split('\n'); socket = new WebSocket(`ws://127.0.0.1:${debugPort}${browserPath}`); await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; }); let nextId = 0; const pending = new Map(); socket.onmessage = event => { const message = JSON.parse(event.data); if (message.method === 'Runtime.exceptionThrown') errors.push(message.params.exceptionDetails.exception?.description || message.params.exceptionDetails.text); if (message.id && pending.has(message.id)) { const pair = pending.get(message.id); pending.delete(message.id); message.error ? pair.reject(new Error(message.error.message)) : pair.resolve(message.result); } }; const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => { const id = ++nextId; const timer = setTimeout(() => { pending.delete(id); reject(new Error('CDP timeout: ' + method)); }, 60000); pending.set(id, {resolve: value => { clearTimeout(timer); resolve(value); }, reject: error => { clearTimeout(timer); reject(error); }}); socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); }); const target = await send('Target.createTarget', { url: 'about:blank' }); const attached = await send('Target.attachToTarget', { targetId: target.targetId, flatten: true }); const session = attached.sessionId; await send('Runtime.enable', {}, session); await send('Emulation.setDeviceMetricsOverride', {width:390,height:844,deviceScaleFactor:1,mobile:true}, session); await send('Emulation.setTouchEmulationEnabled', {enabled:true}, session); await send('Page.navigate', { url: process.env.SLATE_TEST_URL || `http://127.0.0.1:${server.address().port}/?rbdebug&prediction-gate` }, session); const evaluate = async expression => { const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, session); if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text); return result.result.value; }; async function waitFor(expression, label) { for (let i = 0; i < 360; i++) { if (await evaluate(expression)) return; await sleep(100); } const diagnostic = await evaluate(`({status:document.getElementById('status')?.textContent, body:document.body.innerText.slice(-2000)})`); throw new Error(`timeout waiting for ${label}; errors=${errors.join(' | ')}; page=${JSON.stringify(diagnostic)}`); } const dispatch = event => evaluate(`window.slateEval('(slate-dispatch "${event}")')`); await waitFor(`document.getElementById('status')?.textContent === 'ready'`, 'Slate boot'); console.log('PASS browser boot'); const sgl = async code => {const value = await evaluate(`window.slateEval(${JSON.stringify(code)})`); if (/^(Error|error):/.test(value)) throw new Error(value); if (code.includes('(slate-refresh)')) await evaluate('window.slateRefresh()'); return value;}; await waitFor(`window.slateEval('(slate-preferences-settled?)') === '1'`, 'preference restore'); await waitFor(`!document.getElementById('splash')`, 'interactive app'); await evaluate('document.fonts.ready.then(() => true)'); await sgl('(import (slate provider) (slate modes terminal) (slate extend))'); await sgl(`(begin (define compose-context #f) (define compose-writes "") (register-provider! compose-probe: #{ session-open: (lambda (source ctx) (set! compose-context ctx) #{ write: (lambda (bytes) (set! compose-writes (string-append compose-writes bytes))) }) }) (open-view! (make-terminal-view "compose-one" #{kind: pty: provider: compose-probe:})) ((dict-ref compose-context emit:) "$ ") (slate-refresh))`); await waitFor(`document.querySelector('.accessory-key-deck')?.getAttribute('data-compose-target') === 'compose-one'`, 'terminal deck'); await send('Emulation.setFocusEmulationEnabled', {enabled:true}, session); await sgl(`(begin (define geometry-resizes 0) (define geometry-cols 0) (define geometry-rows 0) (register-provider! geometry-probe: #{session-open: (lambda (source ctx) (set! compose-context ctx) #{write: (lambda (bytes) (set! compose-writes (string-append compose-writes bytes))) resize: (lambda (cols rows) (set! geometry-resizes (+ geometry-resizes 1)) (set! geometry-cols cols) (set! geometry-rows rows))})}) (open-view! (make-terminal-view "geometry-probe" #{kind: pty: provider: geometry-probe:})) (slate-refresh))`); const count = () => sgl('(number->string geometry-resizes)'); await waitFor(`window.slateEval('(number->string geometry-cols)') !== '0'`, 'initial terminal sizing'); await sleep(250); const dimensions = () => evaluate(`(() => { const el=document.querySelector('.term-content[data-term="geometry-probe"]') || document.querySelector('.term-content'); const cs=getComputedStyle(el); return { cols:Math.max(2,Math.floor((el.clientWidth-parseFloat(cs.paddingLeft)-parseFloat(cs.paddingRight))/parseFloat(cs.getPropertyValue('--cell-w')))), rows:Math.max(1,Math.floor((el.clientHeight-parseFloat(cs.paddingTop)-parseFloat(cs.paddingBottom))/parseFloat(cs.getPropertyValue('--cell-h')))) }; })()`); async function assertSize() { const expected = await dimensions(); assert.equal(Number(await sgl('(number->string geometry-cols)')), expected.cols); assert.equal(Number(await sgl('(number->string geometry-rows)')), expected.rows); } for (const [from,to] of [[844,460],[460,844]]) { await sgl('(set! geometry-resizes 0)'); // Drive the same viewport CSS variable used by Android keyboard events. const during = await evaluate(`(async () => { for(let i=1;i<=40;i++) { document.documentElement.style.setProperty('--vvh', (${from}+(${to}-${from})*i/40)+'px'); await new Promise(requestAnimationFrame); } return window.slateEval('(number->string geometry-resizes)'); })()`); assert.equal(during, '0', 'no PTY resize during continuous keyboard animation'); await sleep(250); assert.equal(await count(), '1', 'one final PTY resize per keyboard animation'); await assertSize(); } await sgl('(set! geometry-resizes 0)'); await send('Emulation.setDeviceMetricsOverride', {width:844,height:390,deviceScaleFactor:1,mobile:true}, session); await sleep(300); assert.equal(await count(), '1', 'orientation change reaches the provider once'); await assertSize(); await send('Emulation.setDeviceMetricsOverride', {width:390,height:844,deviceScaleFactor:1,mobile:true}, session); await sleep(300); await assertSize(); console.log('PASS keyboard show/hide coalesces to one final resize; orientation and initial sizing fit the actual terminal'); // Convert JSON control-character escapes to Sigil string syntax. await sgl('((dict-ref compose-context emit:) '+JSON.stringify('\x1b[?1000h\x1b[?1006h').replace(/\\u([0-9a-f]{4})/g,'\\x$1;')+')'); await evaluate('window.slateTerm.arm("geometry-test")'); const point = await evaluate(`(() => {const el=document.querySelector('.term-content'); const r=el.getBoundingClientRect(); return {x:r.x+60,y:r.y+r.height/2,cellH:parseFloat(getComputedStyle(el).getPropertyValue('--cell-h'))};})()`); const writes = () => sgl('compose-writes'); const clear = () => sgl('(set! compose-writes "")'); const wheelReports = text => [...text.matchAll(/\x1b\[<(64|65);\d+;\d+M/g)].map(match => Number(match[1])); async function drag(distance, steps=8) { await clear(); await send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[{x:point.x,y:point.y}]},session); for(let i=1;i<=steps;i++) await send('Input.dispatchTouchEvent',{ type:'touchMove',touchPoints:[{x:point.x,y:point.y+distance*i/steps}] },session); await send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]},session); await sleep(60); return await writes(); } assert.equal(wheelReports(await drag(8,1)).length,0,'small movement remains a tap'); const distance = Math.max(24, point.cellH*3)*3.2; for (const sign of [1,-1]) { const result = await drag(sign*distance); assert.deepEqual(wheelReports(result),Array(6).fill(sign>0?64:65),'same travel produces six reports instead of three'); const before = result; await sleep(200); assert.equal(await writes(),before,'no scroll reports after touch ends'); } await clear(); await send('Input.dispatchMouseEvent', {type:'mouseWheel',x:point.x,y:point.y,deltaX:0,deltaY:-100}, session); await sleep(100); assert.deepEqual(wheelReports(await writes()),[64],'physical mouse wheel stays at one report per detent'); console.log('PASS trusted touch scrolls in both directions at 2x sensitivity; tap slop, stop behavior and physical wheel preserved'); assert.deepEqual(errors, []);} finally { if (socket) socket.close(); chrome.kill('SIGTERM'); await Promise.race([chromeExit, sleep(2000)]); await new Promise(resolve => server.close(resolve)); try { fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch (_) { /* Chrome helpers may finish touching the disposable profile late. */ }}packages/slate/test/terminal-mobile-input-policy-test.mjsadded
import assert from 'node:assert/strict';import fs from 'node:fs';const html = fs.readFileSync(new URL('../assets/index.html', import.meta.url), 'utf8');const accessory = fs.readFileSync( new URL('../src/slate/accessory.sgl', import.meta.url), 'utf8');assert.match(html, /cap\.setAttribute\('type', 'password'\)/);assert.match(html, /cap\.setAttribute\('autocorrect', 'off'\)/);assert.match(html, /cap\.setAttribute\('autocapitalize', 'none'\)/);assert.match(html, /cap\.setAttribute\('spellcheck', 'false'\)/);for (const label of ['ESC', '/', '-', 'HOME', '↑', 'END', 'PGUP', 'TAB', 'CTRL', 'ALT', '←', '↓', '→', 'PGDN']) { assert.ok(accessory.includes(`label: "${label}"`), `missing terminal key ${label}`);}assert.match(accessory, /class "accessory-key-deck"/);assert.match(html, /grid-template-columns: repeat\(7,/);assert.match(html, /height: 32px/);assert.match(html, /border-radius: 0/);assert.match(html, /background: var\(--key-deck-bg\)/);assert.match(accessory, /data-kbd-toggle "1"/);assert.match(html, /closest\('\.accessory-bar, \.accessory-key-deck'\)/);assert.match(html, /@media \(max-width: 767px\) \{\s*\.accessory-key-deck/s);assert.match(html, /inputType === 'deleteContentBackward'/);assert.match(html, /navigator\.vibrate\(35\)/);assert.match(html, /const CAP_SENTINEL = '\\u200b'/);assert.match(html, /armCapture\(cap\)/);assert.match(html, /--bottom-chrome-h: calc\(100px \+ env\(safe-area-inset-bottom\)\)/);assert.match(html, /\.ledge-msgs, \.ledge-notif, \.ledge-ind[\s\S]*min-width: 36px; height: 36px/);assert.match(html, /var\(--bottom-chrome-h, var\(--ledge-h\)\)/);console.log('terminal mobile input: literal capture and two-row key deck');packages/slate/test/terminal-prediction-browser-gate.mjsadded
// E2 extraction gate: drive the command module through the shipped WASM.import assert from 'node:assert/strict';import fs from 'node:fs';import http from 'node:http';import os from 'node:os';import path from 'node:path';import { spawn } from 'node:child_process';const root = path.resolve(import.meta.dirname, '../../..');const webRoot = process.env.SLATE_TEST_WEB_ROOT || path.join(root, 'build/web');assert.ok(fs.existsSync(path.join(webRoot, 'slate.wasm')), 'build/web/slate.wasm is required');const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));const types = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.wasm': 'application/wasm', '.json': 'application/json', '.svg': 'image/svg+xml', '.woff2': 'font/woff2' };const server = http.createServer((req, res) => { const relative = req.url.split('?')[0] === '/' ? 'index.html' : req.url.split('?')[0].slice(1); const target = path.resolve(webRoot, relative); if (!target.startsWith(webRoot + path.sep) || !fs.existsSync(target)) { res.writeHead(404).end(); return; } res.setHeader('content-type', types[path.extname(target)] || 'application/octet-stream'); fs.createReadStream(target).pipe(res);});await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'slate-prediction-chrome-'));const chrome = spawn('google-chrome', ['--headless=new', '--no-sandbox', '--disable-gpu', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'],{ stdio: ['ignore', 'ignore', 'pipe'] });const chromeExit = new Promise(resolve => chrome.once('exit', resolve));let socket;const errors = [];try { const activePort = path.join(profile, 'DevToolsActivePort'); for (let i = 0; i < 120 && !fs.existsSync(activePort); i++) await sleep(50); assert.ok(fs.existsSync(activePort), 'Chrome did not expose DevTools'); const [debugPort, browserPath] = fs.readFileSync(activePort, 'utf8').trim().split('\n'); socket = new WebSocket(`ws://127.0.0.1:${debugPort}${browserPath}`); await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; }); let nextId = 0; const pending = new Map(); socket.onmessage = event => { const message = JSON.parse(event.data); if (message.method === 'Runtime.exceptionThrown') errors.push(message.params.exceptionDetails.exception?.description || message.params.exceptionDetails.text); if (message.id && pending.has(message.id)) { const pair = pending.get(message.id); pending.delete(message.id); message.error ? pair.reject(new Error(message.error.message)) : pair.resolve(message.result); } }; const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => { const id = ++nextId; const timer = setTimeout(() => { pending.delete(id); reject(new Error('CDP timeout: ' + method)); }, 15000); pending.set(id, {resolve: value => { clearTimeout(timer); resolve(value); }, reject: error => { clearTimeout(timer); reject(error); }}); socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); }); const target = await send('Target.createTarget', { url: 'about:blank' }); const attached = await send('Target.attachToTarget', { targetId: target.targetId, flatten: true }); const session = attached.sessionId; await send('Runtime.enable', {}, session); // Prediction deliberately clears on window blur. Keep this isolated CDP tab // focused so host window-manager events cannot interrupt the latency probe. await send('Emulation.setFocusEmulationEnabled', {enabled:true}, session); await send('Page.bringToFront', {}, session); await send('Page.navigate', { url: process.env.SLATE_TEST_URL || `http://127.0.0.1:${server.address().port}/?rbdebug&prediction-gate` }, session); const evaluate = async expression => { const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, session); if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text); return result.result.value; }; async function waitFor(expression, label) { for (let i = 0; i < 360; i++) { if (await evaluate(expression)) return; await sleep(100); } const diagnostic = await evaluate(`({status:document.getElementById('status')?.textContent, body:document.body.innerText.slice(-2000)})`); throw new Error(`timeout waiting for ${label}; errors=${errors.join(' | ')}; page=${JSON.stringify(diagnostic)}`); } const dispatch = event => evaluate(`window.slateEval('(slate-dispatch "${event}")')`); await waitFor(`document.getElementById('status')?.textContent === 'ready'`, 'Slate boot'); console.log('PASS browser boot'); const sgl = async code => {const value = await evaluate(`window.slateEval(${JSON.stringify(code)})`); if (/^(Error|error):/.test(value)) throw new Error(value); return value;}; await waitFor(`window.slateEval('(slate-preferences-settled?)') === '1'`, 'preference restore'); await waitFor(`!document.getElementById('splash')`, 'interactive app'); await evaluate('document.fonts.ready.then(() => true)'); await sgl('(import (slate provider) (slate modes terminal) (slate extend))'); await sgl(`(begin (define prediction-context #f) (define prediction-writes "") (register-provider! prediction-probe: #{ session-open: (lambda (source ctx) (set! prediction-context ctx) #{ write: (lambda (bytes) (set! prediction-writes (string-append prediction-writes bytes))) }) }) (open-view! (make-terminal-view "prediction-probe" #{ kind: pty: provider: prediction-probe: })) ((dict-ref prediction-context emit:) "$ ") (slate-refresh))`); await evaluate('window.slateTerm.ensure(); window.slateTerm.flush()'); await waitFor(`document.querySelector('[data-term="prediction-probe"] .tr')`, 'probe terminal'); assert.equal((await evaluate('window.slatePredictEcho.report()')).enabled, false); await dispatch('terminal-predictive-echo:toggle'); await evaluate('window.slateTerm.ensure(); window.slateTerm.flush()'); assert.equal((await evaluate('window.slatePredictEcho.report()')).enabled, true); console.log('PASS prediction option and probe setup'); const rect = await evaluate(`(() => {const r=document.querySelector('[data-term="prediction-probe"]').getBoundingClientRect(); return {x:r.x+30,y:r.y+20}})()`); await send('Input.dispatchMouseEvent', {type:'mousePressed', ...rect, button:'left', clickCount:1}, session); await send('Input.dispatchMouseEvent', {type:'mouseReleased', ...rect, button:'left', clickCount:1}, session); async function key(value) { const params = {key: value, code: 'Key'+value.toUpperCase(), text: value}; await send('Input.dispatchKeyEvent', {type:'keyDown', ...params}, session); await send('Input.dispatchKeyEvent', {type:'keyUp', ...params}, session); } async function output(text) { const literal = JSON.stringify(text).replace(/\\u([0-9a-f]{4})/g, '\\x$1;'); await sgl(`((dict-ref prediction-context emit:) ${literal})`); await evaluate('window.slateTerm.flush()'); } const overlay = () => evaluate(`document.querySelector('.term-prediction')?.textContent || ''`); const report = () => evaluate('window.slatePredictEcho.report()'); for (const ch of 'ab') { await key(ch); assert.equal(await overlay(), ''); await sleep(190); await output(ch); } assert.equal((await report()).terminals[0].armed, true, 'two real delayed echoes arm prediction'); await key('c'); assert.equal(await overlay(), 'c'); if (process.env.SLATE_TEST_SCREENSHOT_DIR) { fs.mkdirSync(process.env.SLATE_TEST_SCREENSHOT_DIR, {recursive: true}); const shot = await send('Page.captureScreenshot', {format: 'png'}, session); fs.writeFileSync(path.join(process.env.SLATE_TEST_SCREENSHOT_DIR, 'prediction.png'), Buffer.from(shot.data, 'base64')); } const realFrame = await sgl('(slate-term-take-frame "prediction-probe" 1)'); assert.ok(realFrame.includes('\np\t$ ab'), 'authoritative VT has no predicted c'); await sleep(260); assert.equal(await evaluate(`document.querySelector('.term-prediction').classList.contains('stale')`), true); await output('c'); assert.equal(await overlay(), '', 'authoritative echo removes overlay'); assert.equal((await report()).terminals[0].confirmed, 3); await key('d'); assert.equal(await overlay(), 'd'); await output('X'); assert.equal(await overlay(), '', 'mismatched echo rolls back'); assert.equal((await report()).terminals[0].mismatches, 1); assert.equal(await sgl('prediction-writes'), 'abcd', 'prediction sends no extra input'); // Performance capture suppresses prediction even if the saved option is on. await evaluate('window.slatePerf.start()'); assert.equal((await report()).suppressed, true); const count = (await report()).overlayWrites; for (const ch of 'efg') { await key(ch); await sleep(190); await output(ch); } assert.equal(await overlay(), ''); assert.equal((await report()).overlayWrites, count); await evaluate('window.slatePerf.stop()'); // A mode-only frame cannot arm prediction without confirmed input echoes. await output('\x1b[?1000h'); await key('h'); assert.equal(await overlay(), ''); await output('\x1b[?1000l'); if (process.env.SLATE_TEST_TMUX_FIXTURE) { const fixture=JSON.parse(fs.readFileSync(process.env.SLATE_TEST_TMUX_FIXTURE,'utf8')); await output(Buffer.from(fixture.boot,'base64').toString('utf8')); // An actual tmux client enables alternate screen and mouse tracking even // though its foreground program is an ordinary interactive shell. const tmuxFrame=await sgl('(slate-term-take-frame "prediction-probe" 1)'); assert.equal(tmuxFrame.split('\n')[0].split(' ')[7],'1','tmux permits echo learning'); await sleep(1100); // previous mismatch backoff must expire before training for (const [index,echo] of fixture.echoes.entries()) { await key(echo.key); assert.equal(await overlay(),index===2?echo.key:'','tmux learns before predicting'); await sleep(190); await output(Buffer.from(echo.bytes,'base64').toString('utf8')); assert.equal(await overlay(),'','actual tmux echo reconciles the overlay'); } console.log('PASS real tmux alternate-screen/mouse-mode echo learns and predicts without modifying authority'); } // Codex-style full redraws: Unicode prompt, placeholder, then ASCII input. // These are synthetic VT bytes, not a recording of a live Codex session. const redrawCodex = text => output('\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J' + '\x1b[2;1H› ' + text + '\x1b[2;' + (3 + text.length) + 'H\x1b[?25h'); await redrawCodex('Ask anything'); await output('\x1b[2;3H'); await sleep(1100); await key('a'); assert.equal(await overlay(), '', 'Codex placeholder is not predicted over'); await sleep(190); await redrawCodex('a'); for (const [index, ch] of [...'bc'].entries()) { await key(ch); assert.equal(await overlay(), '', 'Codex redraws must establish echo learning'); await sleep(190); await redrawCodex('abc'.slice(0, index + 2)); } assert.equal((await report()).terminals[0].armed, true, 'Codex prompt allows learning'); const writesBeforeCodexPrediction = await sgl('prediction-writes'); await key('d'); assert.equal(await overlay(), 'd'); assert.ok(await evaluate(`getComputedStyle(document.querySelector('.term-prediction'), '::after').boxShadow !== 'none'`), 'predicted cursor has its underscore'); const codexFrame = await sgl('(slate-term-take-frame "prediction-probe" 1)'); assert.equal(codexFrame.split('\n').find(line => line.startsWith('p\t'))?.slice(2).trimEnd(), '› abc', 'Codex prediction leaves the authoritative row unchanged'); await sleep(190); await redrawCodex('abcd'); assert.equal(await overlay(), '', 'Codex redraw reconciles the prediction'); assert.equal(await sgl('prediction-writes'), writesBeforeCodexPrediction + 'd'); console.log('PASS Codex-style Unicode prompt, placeholder, full redraws and predicted cursor underscore'); await dispatch('terminal-predictive-echo:toggle'); await evaluate('window.slateTerm.ensure(); window.slateTerm.flush()'); assert.equal((await report()).enabled, false); // Run the VT-side regression cases in the WASM artifact, whose native VT // bindings are present (the current standalone test harness lacks them). await sgl('(import (sigil string))'); await sgl(`(begin (define prediction-check-count 0) (define (check name got want) (unless (equal? got want) (error name got want)) (set! prediction-check-count (+ prediction-check-count 1))) (define (check-true name got) (check name (and got #t) #t)) (define (contains? s sub) (if (string-contains? s sub) #t #f)) (define (header-field frame n) (list-ref (string-split (car (string-split frame (string (integer->char 10)))) " ") n)) (define ESC-S (string (integer->char 27))))`); const source = fs.readFileSync(path.join(root, 'packages/slate/test/terminal-test.sgl'), 'utf8'); const marker = ';; Predictive echo consumes accepted terminal writes'; assert.ok(source.includes(marker)); await sgl(source.slice(source.indexOf(marker))); assert.equal(await sgl('(number->string prediction-check-count)'), '12'); assert.deepEqual(errors, [], `artifact raised exceptions: ${errors.join(' | ')}`); console.log('PASS predictive echo artifact: default/toggle, real keys, authoritative reconciliation, stale/mismatch, no extra writes, capture suppression, mode safety');} finally { if (socket) socket.close(); chrome.kill('SIGTERM'); await Promise.race([chromeExit, sleep(2000)]); await new Promise(resolve => server.close(resolve)); try { fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch (_) { /* Chrome helpers may finish touching the disposable profile late. */ }}packages/slate/test/terminal-prediction-test.mjsadded
import assert from 'node:assert/strict';import { TerminalPrediction } from '../assets/terminal-prediction.mjs';let passed = 0;function test(name, body) { body(); passed++; console.log(`PASS ${name}`);}function frame(text = '$ ', serial = 0, extra = {}) { return { row: 0, col: text.length, cols: 80, serial, safe: true, text: text.padEnd(80, ' '), ...extra };}function warm() { const model = new TerminalPrediction(); model.update(frame(), 0); model.input('a', 0, 1); assert.equal(model.overlay(2), null); model.update(frame('$ a', 1), 201); model.input('b', 1, 220); model.update(frame('$ ab', 2), 420); assert.equal(model.armed, true); return model;}test('two independent slow echoes arm an append-only overlay', () => { const model = warm(); const original = { ...model.frame }; model.input('c', 2, 430); model.input('d', 2, 440); assert.equal(model.overlay(445).text, 'cd'); assert.deepEqual(model.frame, original, 'prediction must not change authority'); model.update(frame('$ abc', 3), 630); assert.equal(model.overlay(631).text, 'd'); assert.equal(model.overlay(631).col, 5); model.update(frame('$ abcd', 4), 640); assert.equal(model.overlay(641), null); assert.equal(model.stats.confirmed, 4);});test('one coalesced output burst cannot arm prediction', () => { const model = new TerminalPrediction(); model.update(frame(), 0); model.input('a', 0, 1); model.input('b', 0, 2); model.update(frame('$ ab', 1), 200); assert.equal(model.stats.confirmed, 2); assert.equal(model.armed, false);});test('Codex prompt learns echoes after its placeholder disappears', () => { const model = new TerminalPrediction(); model.update(frame('› Ask anything', 0, {col: 2}), 0); model.input('a', 0, 1); assert.equal(model.pending.length, 0, 'placeholder prevents append prediction'); model.update(frame('› a', 1), 201); for (const [index, key] of [...'bc'].entries()) { model.input(key, index + 1, 220 + index * 220); assert.equal(model.overlay(221 + index * 220), null); model.update(frame('› a' + 'bc'.slice(0, index + 1), index + 2), 420 + index * 220); } assert.equal(model.armed, true); model.input('d', 3, 660); assert.equal(model.overlay(661).text, 'd'); assert.equal(model.overlay(661).col, 5, 'prompt glyph occupies one cell'); model.update(frame('› abcd', 4), 860); assert.equal(model.overlay(861), null); assert.equal(model.stats.confirmed, 3);});for (const text of ['界 ', 'e\u0301 ', '😀 ', '› 界 ']) test(`ambiguous string/cell offsets remain unsafe: ${JSON.stringify(text)}`, () => { assert.equal(new TerminalPrediction().safe(frame(text)), false); });test('an unchanged frame cannot confirm repeated text already on screen', () => { const model = warm(); model.input('b', 2, 430); model.update(frame('$ ab', 2), 500); assert.equal(model.pending.length, 1); assert.equal(model.stats.confirmed, 2);});for (const [name, next] of [ ['wrong cursor position', frame('$ abcc', 3)], ['matching suffix on another row', frame('$ abc', 3, {row: 1})], ['changed prefix with matching echo suffix', frame('! abc', 3)], ['wrong echoed character', frame('$ abx', 3)], ['output without cursor advancement', frame('$ ab', 3)], ['matching text without fresh output', frame('$ abc', 2)],]) test(`reject ${name}`, () => { const model = warm(); model.input('c', 2, 430); model.update(next, 630); assert.equal(model.overlay(631), null); assert.equal(model.armed, false); assert.equal(model.stats.mismatches, 1); assert.equal(model.stats.confirmed, 2);});test('mismatch imposes backoff even after two new slow echoes', () => { const model = warm(); model.input('c', 2, 430); model.update(frame('$ abx', 3), 630); model.input('d', 3, 640); model.update(frame('$ abxd', 4), 840); model.input('e', 4, 850); model.update(frame('$ abxde', 5), 1050); assert.equal(model.armed, false);});test('stale overlay fades and expires without any server output', () => { const model = warm(); model.input('c', 2, 430); assert.equal(model.overlay(679).stale, false); assert.equal(model.overlay(680).stale, true); assert.equal(model.overlay(2430), null); assert.equal(model.pending.length, 0); assert.equal(model.stats.expired, 1);});test('late warming echoes cannot arm after timeout', () => { const model = warm(); model.reset(430); model.input('c', 2, 440); model.update(frame('$ abc', 3), 3000); assert.equal(model.armed, false); assert.equal(model.stats.confirmed, 2);});for (const key of ['', '\r', '\x7f', '\t', '\x1b[A', 'é', '😀', 'ab']) test(`editing, control, paste or non-ASCII input resets: ${JSON.stringify(key)}`, () => { const model = warm(); model.input('c', 2, 430); model.input(key, 2, 431); assert.equal(model.overlay(432), null); assert.equal(model.armed, false); });test('Enter followed by a no-echo prompt never previews its input', () => { const model = warm(); model.input('\r', 2, 430); model.update(frame('Password: ', 3), 630); for (const key of 'secret') model.input(key, 3, 640); assert.equal(model.overlay(650), null); assert.equal(model.armed, false);});for (const extra of [{safe: false}, {text: 'é'.padEnd(80, ' ')}, {text: '$ ab text'.padEnd(80, ' ')}, {col: 79}]) test(`unsafe state abandons predictions: ${JSON.stringify(extra)}`, () => { const model = warm(); model.input('c', 2, 430); model.update(frame('$ ab', 2, extra), 431); assert.equal(model.overlay(432), null); assert.equal(model.armed, false); });test('input against an unpainted output serial is not predicted', () => { const model = warm(); model.input('c', 3, 430); assert.equal(model.overlay(431), null); assert.equal(model.pending.length, 0);});test('two fast echoes disarm prediction', () => { const model = warm(); model.input('c', 2, 430); model.update(frame('$ abc', 3), 450); model.input('d', 3, 460); model.update(frame('$ abcd', 4), 480); assert.equal(model.armed, false);});console.log(`${passed} predictive echo checks passed`);packages/slate/test/terminal-reattach-browser-gate.mjsadded
// Terminal: Reattach picker against the real browser command and provider seam.import assert from 'node:assert/strict';import fs from 'node:fs';import http from 'node:http';import os from 'node:os';import path from 'node:path';import { spawn } from 'node:child_process';const root = path.resolve(import.meta.dirname, '../../..');const webRoot = process.env.SLATE_TEST_WEB_ROOT || path.join(root, 'build/web');assert.ok(fs.existsSync(path.join(webRoot, 'slate.wasm')), 'build/web/slate.wasm is required');const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));const types = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.wasm': 'application/wasm', '.json': 'application/json', '.svg': 'image/svg+xml', '.woff2': 'font/woff2' };const server = http.createServer((req, res) => { const relative = req.url.split('?')[0] === '/' ? 'index.html' : req.url.split('?')[0].slice(1); const target = path.resolve(webRoot, relative); if (!target.startsWith(webRoot + path.sep) || !fs.existsSync(target)) { res.writeHead(404).end(); return; } res.setHeader('content-type', types[path.extname(target)] || 'application/octet-stream'); fs.createReadStream(target).pipe(res);});await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'slate-compose-chrome-'));const chrome = spawn('google-chrome', ['--headless=new', '--no-sandbox', '--disable-gpu', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'],{ stdio: ['ignore', 'ignore', 'pipe'] });const chromeExit = new Promise(resolve => chrome.once('exit', resolve));let socket;const errors = [];try { const activePort = path.join(profile, 'DevToolsActivePort'); for (let i = 0; i < 120 && !fs.existsSync(activePort); i++) await sleep(50); assert.ok(fs.existsSync(activePort), 'Chrome did not expose DevTools'); const [debugPort, browserPath] = fs.readFileSync(activePort, 'utf8').trim().split('\n'); socket = new WebSocket(`ws://127.0.0.1:${debugPort}${browserPath}`); await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; }); let nextId = 0; const pending = new Map(); socket.onmessage = event => { const message = JSON.parse(event.data); if (message.method === 'Runtime.exceptionThrown') errors.push(message.params.exceptionDetails.exception?.description || message.params.exceptionDetails.text); if (message.id && pending.has(message.id)) { const pair = pending.get(message.id); pending.delete(message.id); message.error ? pair.reject(new Error(message.error.message)) : pair.resolve(message.result); } }; const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => { const id = ++nextId; const timer = setTimeout(() => { pending.delete(id); reject(new Error('CDP timeout: ' + method)); }, 60000); pending.set(id, {resolve: value => { clearTimeout(timer); resolve(value); }, reject: error => { clearTimeout(timer); reject(error); }}); socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); }); const target = await send('Target.createTarget', { url: 'about:blank' }); const attached = await send('Target.attachToTarget', { targetId: target.targetId, flatten: true }); const session = attached.sessionId; await send('Runtime.enable', {}, session); await send('Emulation.setDeviceMetricsOverride', {width:390,height:844,deviceScaleFactor:1,mobile:true}, session); await send('Emulation.setTouchEmulationEnabled', {enabled:true}, session); await send('Page.navigate', { url: process.env.SLATE_TEST_URL || `http://127.0.0.1:${server.address().port}/?rbdebug&prediction-gate` }, session); const evaluate = async expression => { const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, session); if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text); return result.result.value; }; async function waitFor(expression, label) { for (let i = 0; i < 360; i++) { if (await evaluate(expression)) return; await sleep(100); } const diagnostic = await evaluate(`({status:document.getElementById('status')?.textContent, body:document.body.innerText.slice(-2000)})`); throw new Error(`timeout waiting for ${label}; errors=${errors.join(' | ')}; page=${JSON.stringify(diagnostic)}`); } const dispatch = event => evaluate(`window.slateEval('(slate-dispatch "${event}")')`); await waitFor(`document.getElementById('status')?.textContent === 'ready'`, 'Slate boot'); console.log('PASS browser boot'); const sgl = async code => {const value = await evaluate(`window.slateEval(${JSON.stringify(code)})`); if (/^(Error|error):/.test(value)) throw new Error(value); if (code.includes('(slate-refresh)')) await evaluate('window.slateRefresh()'); return value;}; await waitFor(`window.slateEval('(slate-preferences-settled?)') === '1'`, 'preference restore'); await waitFor(`!document.getElementById('splash')`, 'interactive app'); await evaluate('document.fonts.ready.then(() => true)'); await sgl('(import (slate provider) (slate modes terminal) (slate extend))'); await sgl(`(begin (define compose-context #f) (define compose-writes "") (register-provider! compose-probe: #{ session-open: (lambda (source ctx) (set! compose-context ctx) #{ write: (lambda (bytes) (set! compose-writes (string-append compose-writes bytes))) }) }) (open-view! (make-terminal-view "compose-one" #{kind: pty: provider: compose-probe:})) ((dict-ref compose-context emit:) "$ ") (slate-refresh))`); await waitFor(`document.querySelector('.accessory-key-deck')?.getAttribute('data-compose-target') === 'compose-one'`, 'terminal deck'); await send('Emulation.setFocusEmulationEnabled', {enabled:true}, session); await sgl('(import (slate commands terminal) (slate providers node) (slate state) (slate main) (slate command) (slate view))'); // Intercept only the catalog RPC; use the production command/prompt/reducer. await sgl(`(begin (define picker-state #f) (install-builtin-command! "picker-snapshot" "Test snapshot" "test" '(test picker) (lambda (state) (set! picker-state state) state)) (define (slate-state) (slate-dispatch "picker-snapshot") picker-state) (define picker-ready #f) (define picker-error #f) (define picker-opens 0) (set! node-list-sessions! (lambda (ready error) (set! picker-ready ready) (set! picker-error error))) (register-provider! local: #{session-open: (lambda (source ctx) (set! picker-opens (+ picker-opens 1)) #{remote-id: (lambda () (dict-ref source attach: #f)) write: (lambda (bytes) #t)})}))`); await dispatch('terminal-reattach'); await sgl(`(picker-ready #{catalog-version: 1 sessions: #[ #{sid: "s-1111111111111111" kind: "pty" alive: #t attached: #f command: "bash" cwd: "/projects" idle: 3600} #{sid: "s-2222222222222222" kind: "process" alive: #t} #{sid: "s-3333333333333333" kind: "pty" alive: #f}] unavailable: 0})`); await evaluate('window.slateRefresh()'); console.log('PASS catalog reply populated live terminal rows'); const candidates = () => sgl('(number->string (length (terminal-session-candidates (slate-state))))'); assert.equal(await candidates(),'1','picker includes only live terminal sessions'); assert.equal(await sgl('(dict-ref (car (terminal-session-candidates (slate-state))) annotation: "")'), 'detached · idle 1h · /projects'); await waitFor("document.body.innerText.includes('Reattach terminal') && document.body.innerText.includes('s-1111111111111111')", 'rendered picker row'); if (process.env.SLATE_TEST_SCREENSHOT) { const shot=await send('Page.captureScreenshot',{format:'png'},session); fs.writeFileSync(process.env.SLATE_TEST_SCREENSHOT,Buffer.from(shot.data,'base64')); } await dispatch('selector-enter'); await waitFor(`window.slateEval('(number->string picker-opens)') === '1'`, 'selected session attachment'); assert.equal(await sgl('(dict-ref (view-source (active-view (slate-state))) attach: #f)'), 's-1111111111111111'); const name = await sgl('(view-name (active-view (slate-state)))'); await dispatch('terminal-reattach'); await sgl('(picker-ready #{catalog-version: 1 sessions: #[#{sid: "s-1111111111111111" kind: "pty" alive: #t attached: #t command: "bash" idle: 0}]})'); await dispatch('selector-pick:0'); assert.equal(await sgl('(number->string picker-opens)'),'1','reattaching an open view does not open another session'); assert.equal(await sgl('(view-name (active-view (slate-state)))'),name); await dispatch('terminal-reattach'); await dispatch('selector-escape'); await sgl('(picker-ready #{catalog-version: 1 sessions: #[#{sid: "s-4444444444444444" kind: "pty" alive: #t}]})'); assert.notEqual(await sgl('(dict-ref (slate-state) slate/minibuffer: #f)'), 'terminal-session','late catalog reply does not reopen cancelled picker'); await dispatch('terminal-reattach'); await sgl('(picker-ready #{catalog-version: 1 sessions: #[] unavailable: 2})'); assert.match(await sgl('(dict-ref (slate-state) slate/echo: "")'), /could not be listed/); await dispatch('terminal-reattach'); await sgl('(picker-error "node is offline")'); assert.match(await sgl('(dict-ref (slate-state) slate/echo: "")'), /node is offline/); await dispatch('terminal-reattach'); await sgl('(picker-ready #{sessions: #[]})'); assert.match(await sgl('(dict-ref (slate-state) slate/echo: "")'), /Update this node/); await sgl('(set! node-list-sessions! (lambda (ok err) (err "local node is not connected")))'); await dispatch('terminal-reattach'); assert.match(await sgl('(dict-ref (slate-state) slate/echo: "")'), /local node is not connected/); console.log('PASS Terminal: Reattach lists live PTYs, attaches by SID, focuses existing views, ignores cancelled replies and reports unavailable nodes'); await sgl('(import (slate enclave-account session) (slate providers enclave-node) (sigil json))'); await sgl('(begin (define picker-wire #f) (set! node-ready? (lambda () #f)) (set! account-session-send-command! (lambda args (set! picker-wire args) #t)))'); await sgl('(remote-session-open #{kind: pty: host: "remote-test" attach: "s-5555555555555555" attach-route: "relay"} #{sid: "picker-wire-test"})'); assert.equal(await sgl('(list-ref picker-wire 3)'), 'stream/open'); assert.equal(await sgl('(dict-ref (dict-ref (json-decode (list-ref picker-wire 4)) args: #{}) sid: #f)'), 's-5555555555555555'); console.log('PASS remote holder routing carries the selected SID instead of creating a shell'); assert.deepEqual(errors, []);} finally { if (socket) socket.close(); chrome.kill('SIGTERM'); await Promise.race([chromeExit, sleep(2000)]); await new Promise(resolve => server.close(resolve)); try { fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch (_) { /* Chrome helpers may finish touching the disposable profile late. */ }}packages/slate/test/terminal-resize-browser-probe.mjsadded
// Compare output, resizing, and frozen-copy frame costs in a disposable mobile browser.// Mobile terminal deck gate: real touch events and native text input against the shipped WASM.import assert from 'node:assert/strict';import fs from 'node:fs';import http from 'node:http';import os from 'node:os';import path from 'node:path';import { spawn } from 'node:child_process';const root = path.resolve(import.meta.dirname, '../../..');const webRoot = process.env.SLATE_TEST_WEB_ROOT || path.join(root, 'build/web');assert.ok(fs.existsSync(path.join(webRoot, 'slate.wasm')), 'build/web/slate.wasm is required');const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));const types = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.wasm': 'application/wasm', '.json': 'application/json', '.svg': 'image/svg+xml', '.woff2': 'font/woff2' };const server = http.createServer((req, res) => { const relative = req.url.split('?')[0] === '/' ? 'index.html' : req.url.split('?')[0].slice(1); const target = path.resolve(webRoot, relative); if (!target.startsWith(webRoot + path.sep) || !fs.existsSync(target)) { res.writeHead(404).end(); return; } res.setHeader('content-type', types[path.extname(target)] || 'application/octet-stream'); fs.createReadStream(target).pipe(res);});await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'slate-compose-chrome-'));const chrome = spawn('google-chrome', ['--headless=new', '--no-sandbox', '--disable-gpu', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'],{ stdio: ['ignore', 'ignore', 'pipe'] });const chromeExit = new Promise(resolve => chrome.once('exit', resolve));let socket;const errors = [];try { const activePort = path.join(profile, 'DevToolsActivePort'); for (let i = 0; i < 120 && !fs.existsSync(activePort); i++) await sleep(50); assert.ok(fs.existsSync(activePort), 'Chrome did not expose DevTools'); const [debugPort, browserPath] = fs.readFileSync(activePort, 'utf8').trim().split('\n'); socket = new WebSocket(`ws://127.0.0.1:${debugPort}${browserPath}`); await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; }); let nextId = 0; const pending = new Map(); socket.onmessage = event => { const message = JSON.parse(event.data); if (message.method === 'Runtime.exceptionThrown') errors.push(message.params.exceptionDetails.exception?.description || message.params.exceptionDetails.text); if (message.id && pending.has(message.id)) { const pair = pending.get(message.id); pending.delete(message.id); message.error ? pair.reject(new Error(message.error.message)) : pair.resolve(message.result); } }; const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => { const id = ++nextId; const timer = setTimeout(() => { pending.delete(id); reject(new Error('CDP timeout: ' + method)); }, 60000); pending.set(id, {resolve: value => { clearTimeout(timer); resolve(value); }, reject: error => { clearTimeout(timer); reject(error); }}); socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); }); const target = await send('Target.createTarget', { url: 'about:blank' }); const attached = await send('Target.attachToTarget', { targetId: target.targetId, flatten: true }); const session = attached.sessionId; await send('Runtime.enable', {}, session); await send('Emulation.setDeviceMetricsOverride', {width:390,height:844,deviceScaleFactor:1,mobile:true}, session); await send('Emulation.setTouchEmulationEnabled', {enabled:true}, session); await send('Page.navigate', { url: process.env.SLATE_TEST_URL || `http://127.0.0.1:${server.address().port}/?rbdebug&prediction-gate` }, session); const evaluate = async expression => { const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, session); if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text); return result.result.value; }; async function waitFor(expression, label) { for (let i = 0; i < 360; i++) { if (await evaluate(expression)) return; await sleep(100); } const diagnostic = await evaluate(`({status:document.getElementById('status')?.textContent, body:document.body.innerText.slice(-2000)})`); throw new Error(`timeout waiting for ${label}; errors=${errors.join(' | ')}; page=${JSON.stringify(diagnostic)}`); } const dispatch = event => evaluate(`window.slateEval('(slate-dispatch "${event}")')`); await waitFor(`document.getElementById('status')?.textContent === 'ready'`, 'Slate boot'); console.log('PASS browser boot'); const sgl = async code => {const value = await evaluate(`window.slateEval(${JSON.stringify(code)})`); if (/^(Error|error):/.test(value)) throw new Error(value); if (code.includes('(slate-refresh)')) await evaluate('window.slateRefresh()'); return value;}; await waitFor(`window.slateEval('(slate-preferences-settled?)') === '1'`, 'preference restore'); await waitFor(`!document.getElementById('splash')`, 'interactive app'); await evaluate('document.fonts.ready.then(() => true)'); await sgl('(import (slate provider) (slate modes terminal) (slate extend))'); await sgl(`(begin (define compose-context #f) (define compose-writes "") (register-provider! compose-probe: #{ session-open: (lambda (source ctx) (set! compose-context ctx) #{ write: (lambda (bytes) (set! compose-writes (string-append compose-writes bytes))) }) }) (open-view! (make-terminal-view "compose-one" #{kind: pty: provider: compose-probe:})) ((dict-ref compose-context emit:) "$ ") (slate-refresh))`); await waitFor(`document.querySelector('.accessory-key-deck')?.getAttribute('data-compose-target') === 'compose-one'`, 'terminal deck'); await send('Emulation.setFocusEmulationEnabled',{enabled:true},session); await sgl('(begin (define perf-resizes 0) (define perf-resize-output "") (register-provider! resize-probe: #{session-open: (lambda (source ctx) (set! compose-context ctx) #{write: (lambda (bytes) #t) resize: (lambda (cols rows) (set! perf-resizes (+ perf-resizes 1)) ((dict-ref ctx emit:) perf-resize-output))})}) (open-view! (make-terminal-view "resize-probe" #{kind: pty: provider: resize-probe:})) (slate-refresh))'); const line='\x1b[32mBuild output\x1b[0m: '+('terminal streaming text ').repeat(3)+'\r\n'; const payload=line.repeat(32); await sgl('(define perf-payload '+JSON.stringify(payload)+')'); await sgl('(set! perf-resize-output '+JSON.stringify('\x1b[H'+line.repeat(20))+')'); await send('Emulation.setCPUThrottlingRate',{rate:4},session); const results=[]; for(const scenario of ['quiet-resize','output-only','output-resize','frozen-output-resize']) { await send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true},session); await sleep(350); if(scenario==='frozen-output-resize') { await sgl('(slate-dispatch "acc-cat:terminal-copy")'); await evaluate('document.querySelector(\'.accessory-bar [data-terminal-copy="start"]\').click()'); } await sgl('(set! perf-resizes 0)'); await evaluate('window.slatePerf.start(); window.perfFrames=[]; window.perfFeedTimes=[]; window.perfLastFrame=performance.now(); window.perfRunning=true; window.perfFrame=()=>{if(!window.perfRunning)return;const now=performance.now();window.perfFrames.push(now-window.perfLastFrame);window.perfLastFrame=now;requestAnimationFrame(window.perfFrame)}; requestAnimationFrame(window.perfFrame)'); if(scenario!=='quiet-resize') await evaluate('window.perfFeedTimer=setInterval(()=>{const begin=performance.now(); window.slateEval(\'((dict-ref compose-context emit:) perf-payload)\'); window.slateTerm.arm("load-probe"); window.perfFeedTimes.push(performance.now()-begin);},25)'); for(let i=0;i<24;i++) { if(scenario!=='output-only') { const heights=[844,760,650,520,460,520,650,760]; const landscape=i>=16&&i<20; await send('Emulation.setDeviceMetricsOverride',{width:landscape?844:390,height:landscape?390:heights[i%8],deviceScaleFactor:1,mobile:true},session); } await sleep(100); } await evaluate('clearInterval(window.perfFeedTimer); window.perfRunning=false'); await sleep(250); const report=await evaluate('window.slatePerf.stop()'); const extra=await evaluate('({frames:window.perfFrames, feeds:window.perfFeedTimes})'); const stats=xs=>{xs.sort((a,b)=>a-b);return {n:xs.length,p50:xs[Math.floor(xs.length*.5)],p95:xs[Math.floor(xs.length*.95)],max:xs.at(-1)}}; const result={scenario,resizeCount:await sgl('(number->string perf-resizes)'),frames:stats(extra.frames),feed:stats(extra.feeds),summary:report.summary}; results.push(result); console.log(JSON.stringify(result)); if(scenario==='frozen-output-resize') await evaluate('window.slateCopy.key("C-g")'); } if(process.env.SLATE_TEST_PROFILE_OUTPUT) fs.writeFileSync(process.env.SLATE_TEST_PROFILE_OUTPUT,JSON.stringify(results,null,2));} finally { if (socket) socket.close(); chrome.kill('SIGTERM'); await Promise.race([chromeExit, sleep(2000)]); await new Promise(resolve => server.close(resolve)); try { fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch (_) { /* Chrome helpers may finish touching the disposable profile late. */ }}packages/slate/test/terminal-resize-policy-test.mjsadded
// Exercise the shipped observer callbacks with a deterministic clock. A keyboard// animation must finish before either the PTY or tmux client learns its new size.import assert from 'node:assert/strict';import fs from 'node:fs';import vm from 'node:vm';const html = fs.readFileSync(new URL('../assets/index.html', import.meta.url), 'utf8');for (const kernel of ['TERM', 'TMUXSZ']) { const start = html.indexOf('t.ro = new ResizeObserver(() => {', html.indexOf(`const ${kernel} =`)); const end = html.indexOf('\n t.ro.observe(el);', start); assert.ok(start > 0 && end > start); let now = 1000, timerId = 0, size = 40; const timers = new Map(), reports = []; const report = () => reports.push(size); const t = { rszTimer: null, lastRsz: 0 }; vm.runInNewContext(html.slice(start, end), { t, name: 'resize-test', Date: { now: () => now }, report, reportSize: report, ResizeObserver: class { constructor(callback) { this.notify = callback; } }, clearTimeout: id => timers.delete(id), setTimeout: (callback, delay) => { const id = ++timerId; timers.set(id, { at: now + delay, callback }); return id; }, }); function advance(ms) { const until = now + ms; for (;;) { const next = [...timers.entries()].sort((a, b) => a[1].at - b[1].at)[0]; if (!next || next[1].at > until) break; timers.delete(next[0]); now = next[1].at; next[1].callback(); } now = until; } for (const target of [20, 40]) { const from = size; for (let step = 1; step <= 40; step++) { size = from + (target - from) * step / 40; t.ro.notify(); advance(16); } assert.equal(reports.length, target === 20 ? 0 : 1, `${kernel}: no intermediate sizes`); advance(119 - 16); assert.equal(reports.length, target === 20 ? 0 : 1, `${kernel}: wait for quiet geometry`); advance(1); assert.equal(reports.at(-1), target, `${kernel}: final size delivered`); } assert.deepEqual(reports, [20, 40], `${kernel}: one resize per keyboard transition`); assert.equal(t.rszTimer, null); console.log(`PASS ${kernel}: keyboard animation settles into one final resize in each direction`);}packages/slate/test/terminal-test.sglmodified
(check-true "closed -> not live" (not (terminal-live? "t1")))(check "closed -> empty frames" (terminal-take-frame "t1" 1) "");; Predictive echo consumes accepted terminal writes and authoritative VT;; snapshots, including mode-only changes that do not damage a visible row.(define *prediction-emit* #f)(define *prediction-lost* #f)(define *prediction-reattached* #f)(register-provider! prediction-pty: #{ session-open: (lambda (source ctx) (set! *prediction-emit* (dict-ref ctx emit:)) (set! *prediction-lost* (dict-ref ctx lost:)) (set! *prediction-reattached* (dict-ref ctx reattached:)) #{ write: (lambda (bytes) #t) }) })(define pv (make-terminal-view "prediction" #{ kind: pty: provider: prediction-pty: }))(terminal-send-key! pv #{ key: "a" })(check "disabled prediction has no input handoff" (terminal-take-prediction-input!) "")(terminal-set-prediction-enabled! #t)(*prediction-emit* "$ ")(define pf (terminal-take-frame "prediction" 1))(check "plain terminal is prediction safe" (header-field pf 7) "1")(check-true "prediction frame includes the authoritative cursor row" (contains? pf "\np\t$ "))(terminal-send-key! pv #{ key: "a" })(check "accepted input carries the pre-write output serial" (terminal-take-prediction-input!) (string-append "prediction\n" (header-field pf 8) "\na"))(check "prediction input handoff drains once" (terminal-take-prediction-input!) "")(terminal-send-key! pv #{ key: "c" ctrl-key: #t })(check "control input is an explicit prediction barrier" (terminal-take-prediction-input!) (string-append "prediction\n" (header-field pf 8) "\n"))(*prediction-emit* (string-append ESC-S "[?2004h"))(define pp (terminal-take-frame "prediction" 0))(check "shell bracketed-paste support alone permits ordinary typing" (header-field pp 7) "1")(check-true "mode-only output advances the authoritative serial" (> (string->number (header-field pp 8)) (string->number (header-field pf 8))))(*prediction-emit* (string-append ESC-S "[?1000h"))(check "mouse tracking permits confirmed echo learning" (header-field (terminal-take-frame "prediction" 0) 7) "1")(*prediction-emit* (string-append ESC-S "[?1000l" ESC-S "[?1049h"))(check "alternate screen permits confirmed echo learning" (header-field (terminal-take-frame "prediction" 0) 7) "1")(*prediction-emit* (string-append ESC-S "[?1049l"))(terminal-take-frame "prediction" 1)(*prediction-lost* 1)(*prediction-reattached* 0 2)(check "reattach between frames still invalidates prediction" (header-field (terminal-take-frame "prediction" 0) 7) "0")(check "the following connected frame can learn fresh echoes" (header-field (terminal-take-frame "prediction" 0) 7) "1")(terminal-set-prediction-enabled! #f)(terminal-close! "prediction")packages/slate/test/terminal-tmux-prediction-fixture.mjsadded
// Capture real tmux screen/echo sequences without touching the user's server.import fs from 'node:fs';import os from 'node:os';import path from 'node:path';import {spawn, execFileSync} from 'node:child_process';const dir=fs.mkdtempSync(path.join(os.tmpdir(),'slate-prediction-tmux-'));const socket=path.join(dir,'tmux.sock');const env={...process.env,TERM:'xterm-256color'};delete env.TMUX;const quote=value=>"'"+value.replaceAll("'","'\\''")+"'";const command='stty cols 80 rows 24; exec env -u TMUX TERM=xterm-256color tmux -S '+quote(socket)+ ' -f /dev/null new-session -s prediction '+quote('env PS1="SLATE-PROBE> " bash --noprofile --norc');const proc=spawn('script',['-q','-f','-c',command,'/dev/null'],{env,stdio:['pipe','pipe','pipe']});const exited=new Promise(resolve=>proc.once('exit',resolve));let chunks=[];proc.stdout.on('data',chunk=>chunks.push(chunk));proc.stderr.on('data',chunk=>process.stderr.write(chunk));const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));const drain=()=>{const bytes=Buffer.concat(chunks);chunks=[];return bytes.toString('base64')};let owns=false;try { for(let i=0;i<50&&!fs.existsSync(socket);i++)await sleep(100); const actual=execFileSync('tmux',['-S',socket,'display-message','-p','#{socket_path}'],{env,encoding:'utf8'}).trim(); if(actual!==socket)throw Error('tmux socket isolation failed'); owns=true; execFileSync('tmux',['-S',socket,'set-option','-g','mouse','on'],{env}); await sleep(700); const fixture={boot:drain(),echoes:[]}; for(const key of ['a','b','c']) { proc.stdin.write(key); await sleep(250); fixture.echoes.push({key,bytes:drain()}); } const output=process.argv[2]; if(!output)throw Error('Pass an output JSON path'); fs.writeFileSync(output,JSON.stringify(fixture,null,2)); console.log('PASS isolated tmux fixture captured with mouse tracking enabled');} finally { if(owns)try{execFileSync('tmux',['-S',socket,'kill-session','-t','prediction'],{env,stdio:'ignore'})}catch{} proc.kill('SIGTERM'); await Promise.race([exited,sleep(2000)]); fs.rmSync(dir,{recursive:true,force:true});}packages/slate/test/tmux-ladder-test.sglmodified
(import (sigil test) (sigil core) (sigil string) (slate root) (slate tmux ladder));; A labelled check. `assert-true` carries no message, so attribution comes from(test-group "tmux-node-label" (test "a discovered node is named by its nick" (assert-equal "orion" (tmux-node-label "orion"))) (test "an opaque transport is named by its discovered display root" (register-root! nynex: #{ provider: node: host: "slate-node-4f0c767ffc13" label: "nynex" }) (assert-equal "nynex" (tmux-node-label "slate-node-4f0c767ffc13")) (unregister-root! nynex:)) ;; "" IS THE LOCAL NODE, not a missing value — the same convention every ;; remote verb uses for an absent host:. (test "the empty host is the local node, and gets a readable name"packages/slate/test/wake-lock-policy-test.mjsadded
import fs from 'node:fs';import assert from 'node:assert/strict';const html = fs.readFileSync(new URL('../assets/index.html', import.meta.url), 'utf8');const sw = fs.readFileSync(new URL('../assets/sw.js', import.meta.url), 'utf8');const nav = fs.readFileSync(new URL('../src/slate/commands/navigation.sgl', import.meta.url), 'utf8');const prefs = fs.readFileSync(new URL('../src/slate/main/preferences.sgl', import.meta.url), 'utf8');const main = fs.readFileSync(new URL('../src/slate/main.sgl', import.meta.url), 'utf8');assert.match(nav, /"Slate: Keep Screen Awake"/);assert.match(prefs, /define-option slate\/keep-screen-awake:[\s\S]*?default: #f/);assert.match(main, /save-option! slate\/keep-screen-awake: next/);assert.match(html, /navigator\.wakeLock\.request\('screen'\)/);assert.match(html, /document\.visibilityState !== 'visible'/);assert.match(html, /visibilitychange/);assert.match(html, /this browser does not provide Screen Wake Lock/);assert.match(html, /WAKE\.sync\(\)/);assert.match(html, /2026-09-03-wake-lock-1/);assert.match(sw, /2026-09-03-wake-lock-1/);console.log('wake lock policy: persisted opt-in and visible-only reacquisition');packages/slate/test/web-package-contract-test.sgladded
(import (sigil fs) (sigil string) (sigil test))(define package-source (read-file-string "packages/slate/package.sgl"))(test "Slate is the sole writer of the PWA shell" (assert-true (and (string-contains? package-source "html: 'manual") (string-contains? package-source "source-dir: \"assets\""))))(test "the authored PWA shell identifies Slate" (let ((index (read-file-string "packages/slate/assets/index.html"))) (assert-true (and (string-contains? index "<title>Slate</title>") (string-contains? index "manifest.webmanifest")))))probes/local/http-body/check.cjsadded
// From the workspace root, after `sigil deps install`:// SIGIL=/path/to/sigil node probes/local/http-body/check.cjsconst assert = require('node:assert/strict');const net = require('node:net');const {spawn} = require('node:child_process');const path = require('node:path');const delay = ms => new Promise(resolve => setTimeout(resolve, ms));async function exchange(port, expected, payload, splitAt) { const header = Buffer.from(`POST /probe HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nContent-Length: ${Buffer.byteLength(expected)}\r\n\r\n`); return new Promise((resolve, reject) => { const socket = net.connect(port, '127.0.0.1'); const chunks = []; const timer = setTimeout(() => socket.destroy(new Error('response timeout with client still open')), 2000); socket.on('error', reject); socket.on('data', chunk => chunks.push(chunk)); socket.on('close', () => clearTimeout(timer)); socket.on('end', () => { const response = Buffer.concat(chunks); const at = response.indexOf('\r\n\r\n'); try { assert.match(response.subarray(0, at).toString(), /^HTTP\/1\.1 200 /); assert.deepEqual(response.subarray(at + 4), Buffer.from(expected)); resolve(); } catch (error) { reject(error); } }); socket.on('connect', async () => { if (splitAt === null) socket.write(Buffer.concat([header, payload])); else { socket.write(Buffer.concat([header, payload.subarray(0, splitAt)])); await delay(50); if (!socket.destroyed) socket.write(payload.subarray(splitAt)); } }); });}(async () => { const reservation = net.createServer(); await new Promise(resolve => reservation.listen(0, '127.0.0.1', resolve)); const port = reservation.address().port; await new Promise(resolve => reservation.close(resolve)); const child = spawn(process.env.SIGIL || 'sigil', ['-L', 'packages/slate-cli/src', 'eval', '-f', path.join(__dirname, 'server.sgl')], {env: {...process.env, SLATE_HTTP_TEST_PORT: String(port)}, stdio: ['ignore', 'pipe', 'pipe']}); let output = ''; let stopped = false; child.on('exit', () => { stopped = true; }); child.stdout.on('data', chunk => { output += chunk; }); child.stderr.on('data', chunk => { output += chunk; }); const startupError = new Promise((_, reject) => child.once('error', reject)); try { await Promise.race([startupError, (async () => { for (let i = 0; i < 300; i++) { if (output.includes('READY\n')) return; if (stopped) throw new Error(output); await delay(100); } throw new Error(`listener startup timeout: ${output}`); })()]); const cases = [ ['ASCII', 'abc', 'abc', null], ['buffered Unicode JSON', '{"note":"→😀é"}', '{"note":"→😀é"}', null], ['body after headers', '→😀é', '→😀é', 0], ['split three-byte code point', '→😀é', '→😀é', 1], ['split four-byte code point', '→😀é', '→😀é', 5], ['declared byte boundary', '→', '→extra', null], ]; let failed = 0; for (const [name, expected, payload, splitAt] of cases) { try { await exchange(port, expected, Buffer.from(payload), splitAt); console.log(`PASS ${name}`); } catch (error) { failed++; console.log(`FAIL ${name}: ${error.message}`); } } console.log(`${cases.length - failed} passed; ${failed} failed`); if (failed) process.exitCode = 1; } finally { child.kill('SIGTERM'); for (let i = 0; i < 30 && !stopped; i++) await delay(100); if (!stopped) child.kill('SIGKILL'); }})().catch(error => { console.error(error); process.exitCode = 1; });probes/local/http-body/server.sgladded
(import (sigil async) (sigil io) (sigil process) (slate-cli listen));; The external runner supplies a free loopback port and owns this process.(with-async (start-listener! "127.0.0.1" (string->number (getenv "SLATE_HTTP_TEST_PORT")) #f #{ http-handler: (lambda (daemon client method path headers head) (send-http-response! client 200 "text/plain" (dict-ref head body: ""))) }) (display "READY\n") (flush-output-port))release/README.mdadded
# Slate CLI release trustCLI release manifests are signed with the dedicated OpenSSH Ed25519 identity`slate-cli-release-v1` in the `slate-cli-release` signature namespace. Thetrusted public key is recorded in `allowed_signers`; private key material mustnever be stored in this repository.The current local signing key is encrypted by `pass` at:```textsigil/slate/release-signing-v1```Its fingerprint is:```textSHA256:FwFUpe8UFLB4lzmpysY3SkEwjsDIR21mKjUoaaCqYEA```For a local release, use the Sigil wrapper. It retrieves the key withoutprinting it, verifies the fingerprint, scopes the release environment, streamsthe matrix build's output, and securely removes the temporary key when thebuild returns or raises an error:```shscripts/local-release.sgl build 0.1.0 /absolute/path/to/release-candidate```The wrapper uses `dynamic-wind` for cleanup during ordinary success and errorunwinding. An uncatchable process termination or machine failure can bypass anyin-process cleanup; the temporary directory is therefore mode `0700` and thekey is mode `0600`. Check the system temporary directory after such an event.Keep an encrypted backup of the pass store separately from this machine beforerelying on this key for public releases.To verify a manually materialized identity before signing:```shssh-keygen -y -f "$SLATE_RELEASE_SIGNING_KEY" | ssh-keygen -lf -```The reported fingerprint must match the fingerprint above. If the private keyis lost or suspected to be exposed, stop signing with this identity, add a newversioned identity and public key, and publish the rotation through a channelthat does not depend solely on the compromised key.release/allowed_signersadded
slate-cli-release-v1 namespaces="slate-cli-release" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPGBR7QQY0gT7C7pwK45Y1RRAV7wI0B03sTWT8lcxCy1 Slate CLI release signing key v1release/cli-v0.1.3.mdadded
# Slate CLI 0.1.3Slate CLI 0.1.3 completes the first multi-node companion rollout.Highlights:- Preserves remote working directories for terminals and file completion.- Fixes desktop `node status --json` reporting when no Termux control script is present.- Adds persistent macOS installation support without replacing enrollment.- Adds signed OpenBSD amd64 and arm64 release artifacts.- Ships the existing Linux, macOS, Android arm64, and Windows matrix, including the fully static Linux amd64 build for Guix systems.The signed release manifest and artifacts are available under`https://useslate.org/releases/cli/`.scripts/check-git-contractadded
#!/usr/bin/env bashset -euo pipefailrepo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)contract="$repo/packages/slate/test/git-contract-v1.json"gate="$repo/packages/slate/test/git-contract-gate.mjs"for tool in node git; do command -v "$tool" >/dev/null 2>&1 || { echo "SETUP-FAILED git-contract: missing $tool" >&2 exit 2 }done[ -f "$contract" ] && [ -f "$gate" ] || { echo "SETUP-FAILED git-contract: declaration or gate is missing" >&2 exit 2}fixture_root=$(mktemp -d "${TMPDIR:-/tmp}/slate-git-contract.XXXXXX")cleanup() { rm -rf -- "$fixture_root"; }trap cleanup EXIT HUP INT TERMnode "$gate" "$contract" "$fixture_root"scripts/check-git-diff-viewadded
#!/usr/bin/env bashset -euo pipefailroot=$(cd "$(dirname "$0")/.." && pwd)backend="$root/packages/slate-cli/src/slate-cli/git/diff.sgl"dispatch="$root/packages/slate-cli/src/slate-cli/dispatch.sgl"model="$root/packages/slate/src/slate/git/diff-model.sgl"view="$root/packages/slate/src/slate/git/diff-view.sgl"mode="$root/packages/slate/src/slate/modes/git-diff.sgl"commands="$root/packages/slate/src/slate/commands/git.sgl"component="$root/packages/slate/src/slate/components/diff.sgl"for file in "$backend" "$dispatch" "$model" "$view" "$mode" "$commands" "$component"; do [ -f "$file" ] || { echo "SETUP-FAILED git-diff-view: missing $file" >&2; exit 2; }donerg -qF '((string=? verb "git/diff")' "$dispatch" || { echo 'git-diff: node verb missing' >&2; exit 1; }rg -qF 'max-lines: 400 max-bytes: 262144' "$model" || { echo 'diff reply exceeded byte/line ceiling' >&2; exit 1; }rg -qF '(define mounted-row-ceiling 800)' "$view" || { echo 'git-diff: mounted row ceiling missing' >&2; exit 1; }safe_callbacks=$(rg -cF '(= generation (git-diff-model-generation model))' "$model" || true)[ "$safe_callbacks" -eq 2 ] || { echo 'late diff reply mutated current view' >&2; exit 1; }rg -qF '(page (stream-page token stream max-lines max-bytes))' "$backend" || { echo 'diff reply exceeded byte/line ceiling' >&2; exit 1; }rg -qF '(encoded-bytes reply)' "$backend" || { echo 'diff reply exceeded byte/line ceiling' >&2; exit 1; }rg -qF '(stream-remove! cursor stream #t)' "$backend" || { echo 'abandoned diff cursor leaked node process' >&2; exit 1; }rg -qF '(go (expire-stream token stream))' "$backend" || { echo 'abandoned diff cursor leaked node process' >&2; exit 1; }rg -qF 'cancel: #t max-lines: 400 max-bytes: 262144' "$model" || { echo 'abandoned diff cursor leaked node process' >&2; exit 1; }if rg -nF '(git-exec grants worktree (diff-argv kind target))' "$backend"; then echo 'diff reply exceeded byte/line ceiling' >&2; exit 1fifor binding in '"n" "git-next"' '"ArrowDown" "git-next"' '"p" "git-prev"' '"ArrowUp" "git-prev"' '"Enter" "git-visit"' '"q" "git-quit"'; do rg -qF "$binding" "$mode" || { echo "git-diff: mode binding missing: $binding" >&2; exit 1; }donefor command in git-diff git-diff-more git-diff-refresh; do rg -qF "\"$command\"" "$commands" || { echo "git-diff: missing command $command" >&2; exit 1; }donerg -qF 'root: (dict-ref source root: #f)' "$commands" || { echo 'diff visit changed root' >&2; exit 1; }rg -qF '(slate components diff)' "$mode" || { echo 'git-diff: shared control missing' >&2; exit 1; }rg -qF 'data-slate-click' "$component" || { echo 'git-diff: pointer route missing' >&2; exit 1; }node_log=$(mktemp)client_log=$(mktemp)trap 'rm -f "$node_log" "$client_log"' EXITif ! sigil test slate-cli --sgl --no-color "$root/packages/slate-cli/test/test-git-diff-contract.sgl" >"$node_log" 2>&1; then cat "$node_log" >&2 if rg -qF 'abandoned cursors are explicitly cancelled without leaking children' "$node_log"; then echo 'abandoned diff cursor leaked node process' >&2 exit 1 fi echo 'diff reply exceeded byte/line ceiling' >&2 exit 1fiif ! sigil test slate --sgl --no-color "$root/packages/slate/test/git-diff-view-test.sgl" >"$client_log" 2>&1; then cat "$client_log" >&2 echo 'late diff reply mutated current view' >&2 exit 1fiecho 'PASS git-diff-view: paged structured diff; bounded window; remote visit; late reply'scripts/check-git-discard-refreshadded
#!/usr/bin/env bashset -euo pipefailroot="$(cd "$(dirname "$0")/.." && pwd)"commands="$root/packages/slate/src/slate/commands/git.sgl"model="$root/packages/slate/src/slate/git/model.sgl"node="$root/packages/slate-cli/src/slate-cli/git/mutation.sgl"dispatch="$root/packages/slate-cli/src/slate-cli/dispatch.sgl"rg -qF '(if (not yes?) next' "$commands" || { echo 'discard ran after decline' >&2; exit 1; }rg -qF 'This cannot be undone.' "$commands" || { echo 'discard confirmation omits irreversibility' >&2; exit 1; }rg -qF 'git/discard-file' "$dispatch" || { echo 'discard node dispatch missing' >&2; exit 1; }rg -qF 'git-discard-hunk' "$node" || { echo 'discard hunk authority missing' >&2; exit 1; }rg -qF 'external-change?: #t' "$model" || { echo 'passive watch does not mark stale status' >&2; exit 1; }if sed -n '/define (git-status-model-watch-event!/,/^ (define /p' "$model" | rg -qF 'git-status-model-load!'; then echo 'passive watch started Git work' >&2; exit 1fisource "$root/scripts/lib/slate-harness.sh"log="$(mktemp)"trap 'rm -f "$log"' EXITsigil test slate-cli --sgl --no-color \ "$root/packages/slate-cli/test/test-git-discard.sgl" >"$log" 2>&1 || { cat "$log"; exit 1; }harness_verdict "$log" 1sigil test slate --sgl --no-color \ "$root/packages/slate/test/git-status-model-test.sgl" >"$log" 2>&1 || { cat "$log"; exit 1; }harness_verdict "$log" 1echo 'PASS git-discard-refresh: confirmed node discard; stale unchanged; passive watch is notification-only'scripts/check-git-file-mutationsadded
#!/usr/bin/env bashset -euo pipefailroot=$(cd "$(dirname "$0")/.." && pwd)dispatch="$root/packages/slate-cli/src/slate-cli/dispatch.sgl"node="$root/packages/slate-cli/src/slate-cli/git/mutation.sgl"commands="$root/packages/slate/src/slate/commands/git.sgl"mode="$root/packages/slate/src/slate/modes/git-status.sgl"for file in "$dispatch" "$node" "$commands" "$mode"; do [ -f "$file" ] || { echo "SETUP-FAILED git-file-mutations: missing $file" >&2; exit 2; }donefor verb in git/stage-file git/unstage-file; do rg -qF "\"$verb\"" "$dispatch" || { echo "git-file-mutations: missing dispatch $verb" >&2; exit 1; } rg -qF "\"$verb\"" "$commands" || { echo "git-file-mutations: missing client $verb" >&2; exit 1; }donerg -qF '(snapshot=? expected current)' "$node" || { echo 'mutation accepted stale snapshot' >&2; exit 1;}rg -qF 'GIT_INDEX_FILE=' "$node" || { echo 'git-file-mutations: isolated candidate index missing' >&2; exit 1; }rg -qF '(list "apply" "--cached"' "$node" || { echo 'git-file-mutations: atomic index publication missing' >&2; exit 1; }rg -qF 'edit in the validation publication window' "$root/packages/slate-cli/test/test-git-file-mutations.sgl" || { echo 'git-file-mutations: exact-window race fixture missing' >&2; exit 1;}rg -qF 'entry-id:' "$node" || { echo 'git-file-mutations: entry identity guard missing' >&2; exit 1; }for binding in '"s" "git-stage"' '"u" "git-unstage"'; do rg -qF "$binding" "$mode" || { echo "git-file-mutations: binding missing $binding" >&2; exit 1; }donefor family in git-select-stage: git-select-unstage:; do rg -qF "$family" "$mode" || { echo "git-file-mutations: pointer family missing $family" >&2; exit 1; }donecache=$(mktemp -d)log=$(mktemp)source "$root/scripts/lib/slate-harness.sh"trap 'rm -rf "$cache"; rm -f "$log"' EXITif ! XDG_CACHE_HOME="$cache" sigil test slate-cli --sgl --no-color \ "$root/packages/slate-cli/test/test-git-file-mutations.sgl" >"$log" 2>&1; then if rg -qF 'stale snapshot changes neither index nor newer worktree state' "$log"; then echo 'mutation accepted stale snapshot' >&2 fi cat "$log" >&2; exit 1fiharness_verdict "$log" 1if ! XDG_CACHE_HOME="$cache" sigil test slate --sgl --no-color \ "$root/packages/slate/test/git-status-view-test.sgl" \ "$root/packages/slate/test/git-status-model-test.sgl" >"$log" 2>&1; then cat "$log" >&2; exit 1fiharness_verdict "$log" 2echo 'PASS git-file-mutations: atomic stage/unstage; exact-window stale unchanged; keyboard/pointer parity'scripts/check-git-historyadded
#!/usr/bin/env bashset -euo pipefailroot=$(cd "$(dirname "$0")/.." && pwd)source "$root/scripts/lib/slate-harness.sh"log=$(mktemp)trap 'rm -f "$log"' EXITfor package in slate-cli slate; do if [ "$package" = slate-cli ]; then test_file="$root/packages/slate-cli/test/test-git-log.sgl" else test_file="$root/packages/slate/test/git-log-test.sgl" fi if ! sigil test "$package" --sgl --no-color "$test_file" >"$log" 2>&1; then cat "$log" exit 1 fi harness_verdict "$log" 1donenode --check "$root/packages/slate/test/git-log-browser-gate.mjs"echo 'PASS git-history: bounded pages, lazy inspection, root identity, commit topology'scripts/check-git-hunk-mutationsadded
#!/usr/bin/env bashset -euo pipefailroot=$(cd "$(dirname "$0")/.." && pwd)dispatch="$root/packages/slate-cli/src/slate-cli/dispatch.sgl"node="$root/packages/slate-cli/src/slate-cli/git/mutation.sgl"commands="$root/packages/slate/src/slate/commands/git.sgl"mode="$root/packages/slate/src/slate/modes/git-status.sgl"model="$root/packages/slate/src/slate/git/model.sgl"node_test="$root/packages/slate-cli/test/test-git-hunk-mutations.sgl"for file in "$dispatch" "$node" "$commands" "$mode" "$model" "$node_test"; do [ -f "$file" ] || { echo "SETUP-FAILED git-hunk-mutations: missing $file" >&2; exit 2; }donefor verb in git/stage-hunk git/unstage-hunk; do rg -qF "\"$verb\"" "$dispatch" || { echo "git-hunk-mutations: missing dispatch $verb" >&2; exit 1; } rg -qF "\"$verb\"" "$commands" || { echo "git-hunk-mutations: missing client $verb" >&2; exit 1; }donerg -qF '(patch-hunks text side file-id)' "$node" || { echo 'git-hunk-mutations: node reconstruction missing' >&2; exit 1; }rg -qF '(snapshot=? expected latest)' "$node" || { echo 'mutation accepted stale snapshot' >&2; exit 1; }rg -qF 'GIT_INDEX_FILE=' "$node" || { echo 'git-hunk-mutations: isolated candidate index missing' >&2; exit 1; }rg -qF 'hunk-id: hunk-id' "$model" || { echo 'git-hunk-mutations: structured hunk request missing' >&2; exit 1; }if rg -qF 'patch: patch' "$model" "$commands"; then echo 'git-hunk-mutations: rendered patch crossed client boundary' >&2; exit 1fifor family in git-select-stage: git-select-unstage:; do rg -qF "$family" "$mode" || { echo "git-hunk-mutations: pointer family missing $family" >&2; exit 1; }donecache=$(mktemp -d)log=$(mktemp)source "$root/scripts/lib/slate-harness.sh"trap 'rm -rf "$cache"; rm -f "$log"' EXITif ! XDG_CACHE_HOME="$cache" sigil test slate-cli --sgl --no-color "$node_test" >"$log" 2>&1; then if rg -qF 'stage the exact non-first worktree hunk and unstage its exact index hunk' "$log"; then echo 'wrong hunk staged' >&2 fi cat "$log" >&2; exit 1fiharness_verdict "$log" 1if ! XDG_CACHE_HOME="$cache" sigil test slate --sgl --no-color \ "$root/packages/slate/test/git-status-view-test.sgl" \ "$root/packages/slate/test/git-status-model-test.sgl" \ "$root/packages/slate/test/diff-control-test.sgl" >"$log" 2>&1; then cat "$log" >&2; exit 1fiharness_verdict "$log" 3echo 'PASS git-hunk-mutations: node reconstruction; atomic partial index; keyboard/pointer parity'scripts/check-git-inline-diffadded
#!/usr/bin/env bashset -euo pipefailroot=$(cd "$(dirname "$0")/.." && pwd)component="$root/packages/slate/src/slate/components/diff.sgl"status="$root/packages/slate/src/slate/modes/git-status.sgl"dedicated="$root/packages/slate/src/slate/modes/git-diff.sgl"agent="$root/packages/slate/src/slate/modes/agent-session.sgl"commands="$root/packages/slate/src/slate/commands/git.sgl"asset="$root/packages/slate/assets/index.html"for file in "$component" "$status" "$dedicated" "$agent" "$commands" "$asset"; do [ -f "$file" ] || { echo "SETUP-FAILED git-inline-diff: missing $file" >&2; exit 2; }donefor host in "$status" "$dedicated" "$agent"; do rg -qF '(slate components diff)' "$host" || { echo "git-inline-diff: host bypassed shared control: $host" >&2; exit 1; }donerg -qF '.slate-diff-addition { color:var(--ok);' "$asset" || { echo 'git-inline-diff: addition/deletion distinction missing' >&2; exit 1; }rg -qF '.slate-diff-deletion { color:var(--err);' "$asset" || { echo 'git-inline-diff: addition/deletion distinction missing' >&2; exit 1; }rg -qF 'slate-diff-old' "$component" || { echo 'git-inline-diff: line-number gutters missing' >&2; exit 1; }rg -qF 'slate-diff-new' "$component" || { echo 'git-inline-diff: line-number gutters missing' >&2; exit 1; }rg -qF '.slate-diff-control { min-width:0; width:100%; max-width:100%; overflow:hidden;' "$asset" || { echo 'git-inline-diff: narrow pane overflow containment missing' >&2; exit 1;}rg -qF '(define mounted-row-ceiling 800)' "$root/packages/slate/src/slate/git/status-view.sgl" || { echo 'git-inline-diff: mounted row ceiling missing' >&2; exit 1;}rg -qF 'git-inline-more:' "$commands" || { echo 'git-inline-diff: continuation command missing' >&2; exit 1; }cache_dir=${XDG_CACHE_HOME:-$(mktemp -d)}log=$(mktemp)trap 'rm -f "$log"' EXITsource "$root/scripts/lib/slate-harness.sh"if ! XDG_CACHE_HOME="$cache_dir" sigil test slate --sgl --no-color \ "$root/packages/slate/test/diff-control-test.sgl" \ "$root/packages/slate/test/git-status-view-test.sgl" \ "$root/packages/slate/test/git-diff-view-test.sgl" >"$log" 2>&1; then tail -n 80 "$log" >&2 exit 1fiharness_verdict "$log" 3echo 'PASS git-inline-diff: shared control 3/3 hosts; gutters/color; lazy folding; bounded rows'scripts/check-git-node-contractadded
#!/usr/bin/env bashset -euo pipefailrepo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)dispatch="$repo/packages/slate-cli/src/slate-cli/dispatch.sgl"executor="$repo/packages/slate-cli/src/slate-cli/git/exec.sgl"status="$repo/packages/slate-cli/src/slate-cli/git/status.sgl"routing="$repo/packages/slate/test/git-provider-routing-test.sgl"runtime_test="$repo/packages/slate-cli/test/test-git-node-contract.sgl"contract="$repo/packages/slate/test/git-contract-v1.json"for file in "$dispatch" "$executor" "$status" "$routing" "$runtime_test" "$contract"; do [ -f "$file" ] || { echo "SETUP-FAILED git-node-contract: missing $file" >&2; exit 2; }donefail=0while IFS= read -r verb; do case "$verb" in git/discover|git/status) if ! rg -qF "\"$verb\"" "$dispatch"; then echo "$verb: missing dispatch registration" >&2 fail=1 fi ;; esacdone < <(node -e 'const c=require(process.argv[1]); for (const v of Object.keys(c.verbs)) console.log(v)' "$contract")for forbidden in '"sh" "-c"' '"/bin/sh"' '"timeout"' '"env" "--chdir"' '"-C"' 'process-spawn-pipe' 'process-signal!'; do if rg -qF "$forbidden" "$executor" "$status"; then echo "git invocation is not platform portable: $forbidden" >&2 fail=1 fidonerg -qF 'process-spawn' "$executor" || { echo "portable process API missing" >&2; fail=1; }rg -qF 'cwd: cwd' "$executor" || { echo "child-local working directory missing" >&2; fail=1; }rg -qF 'environment: (git-environment)' "$executor" || { echo "explicit child environment missing" >&2; fail=1; }rg -qF '"LC_ALL=C"' "$executor" || { echo "frozen Git locale missing" >&2; fail=1; }rg -qF 'released runtime applies child cwd and environment' "$runtime_test" || { echo "executable child-context runtime probe missing" >&2 fail=1}rg -qF 'process-kill!' "$executor" || { echo "portable timeout cancellation missing" >&2; fail=1; }rg -qF 'output-exceeded?' "$executor" || { echo "bounded output result missing" >&2; fail=1; }for field in 'node/rpc-begin' 'node/rpc-end' 'duration-ms:' 'args-path-summary'; do if ! rg -qF "$field" "$dispatch"; then echo "Git request logging field missing: $field" >&2 fail=1 fidonerg -qF '(dict-ref args repo: #f)' "$dispatch" || { echo "Git status log has no safe repository path summary" >&2 fail=1}local_dest=$(rg -o 'recording-op "node-local"' "$routing" || true)remote_dest=$(rg -o 'recording-op "node-remote"' "$routing" || true)if [ -z "$local_dest" ] || [ -z "$remote_dest" ]; then echo "remote root executed on local node" >&2 fail=1fi[ "$fail" -eq 0 ] || exit 1echo "PASS git-node-contract: git/discover + git/status; root parity 2/2"scripts/check-git-status-modeladded
#!/usr/bin/env bashset -euo pipefailrepo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)model="$repo/packages/slate/src/slate/git/model.sgl"test_file="$repo/packages/slate/test/git-status-model-test.sgl"for file in "$model" "$test_file"; do [ -f "$file" ] || { echo "SETUP-FAILED git-status-model: missing $file" >&2; exit 2; }donefail=0for state in loading stale ready unavailable error; do rg -qF "state: ${state}:" "$model" || { echo "git status model state missing: $state" >&2 fail=1 }donefor seam in 'source-root' 'source-git-request' 'project-source?' 'project-root'; do rg -qF "$seam" "$model" || { echo "git status model seam missing: $seam" >&2 fail=1 }donerg -qF '(keyword->string root)' "$model" || { echo "identity collision: repository key omits root" >&2 fail=1}rg -qF 'same path on different remote roots has no identity collision' "$test_file" || { echo "identity collision arm missing" >&2 fail=1}rg -qF 'late discovery reply cannot overwrite a newer generation' "$test_file" || { echo "late-reply arm missing" >&2 fail=1}rg -qF 'row ids are stable across snapshots' "$test_file" || { echo "cross-snapshot row identity arm missing" >&2 fail=1}rg -qF 'browser-only root is unavailable without a Git request' "$test_file" || { echo "browser unavailable arm missing" >&2 fail=1}if rg -q -e '\(slate providers (node|enclave-node|lantern)\)|\(sigil process\)' "$model"; then echo "git status model imports a concrete transport or process" >&2 fail=1fi[ "$fail" -eq 0 ] || exit 1echo "PASS git-status-model: project/folder; local/remote; same-path/different-root; late-reply"scripts/check-git-status-viewadded
#!/usr/bin/env bashset -euo pipefailroot=$(cd "$(dirname "$0")/.." && pwd)mode="$root/packages/slate/src/slate/modes/git-status.sgl"commands="$root/packages/slate/src/slate/commands/git.sgl"bindings="$root/packages/slate/src/slate/bindings.sgl"main="$root/packages/slate/src/slate/main.sgl"test_file="$root/packages/slate/test/git-status-view-test.sgl"for file in "$mode" "$commands" "$bindings" "$main" "$test_file"; do [ -f "$file" ] || { echo "SETUP-FAILED git-status-view: missing $file" >&2; exit 2; }donerg -qF '"g" "git-status"' "$bindings" || { echo 'git-status: canonical binding missing' >&2; exit 1; }for command in git-status git-next git-prev git-toggle git-visit git-refresh git-help git-quit; do rg -qF "\"$command\"" "$commands" || { echo "git-status: missing command $command" >&2; exit 1; }donefor binding in '"n" "git-next"' '"ArrowDown" "git-next"' '"p" "git-prev"' '"ArrowUp" "git-prev"' '"Tab" "git-toggle"' '"Enter" "git-visit"' '"?" "git-help"' '"q" "git-quit"'; do rg -qF "$binding" "$mode" || { echo "git-status: mode binding missing: $binding" >&2; exit 1; }donerg -qF '(slate commands git)' "$main" || { echo 'git-status: command module not composed' >&2; exit 1; }rg -qF '(configure-git-commands!' "$main" || { echo 'git-status: commands not configured' >&2; exit 1; }rg -qF 'data-slate-click' "$mode" || { echo 'git-status: pointer route missing' >&2; exit 1; }rg -qF 'key: "ArrowDown"' "$mode" || { echo 'git-status: touch key synthesis missing' >&2; exit 1; }if rg -n 'git/diff|patch:' "$mode" "$commands"; then echo 'git-status: eager diff surface present' >&2; exit 1; fiecho 'PASS git-status-view: commands/bindings; keyboard/pointer; zero eager diff'scripts/check-hooks-browser-gatesmodified
node packages/slate/test/notification-browser-gate.mjsnode packages/slate/test/terminal-extraction-browser-gate.mjsnode packages/slate/test/project-folder-extraction-browser-gate.mjsnode packages/slate/test/folder-redesign-browser-gate.mjs# Runs BOTH its branches internally (opfs / fallback), each driving a different# no-init path through run-init. Registered here because nothing else can see a# .mjs: check-suite-shape derives its denominator from .sgl names only and# that never calls back, which only the unveil deadline can rescue).node packages/slate/test/store-backend-browser-gate.mjsecho "OK hooks browser gates: help, save, root OPFS/fallback, hooks docs, notification persistence, terminal extraction, project/folder extraction, scratchpad extend, store backend"echo "OK hooks browser gates: help, save, root OPFS/fallback, hooks docs, notification persistence, terminal extraction, project/folder extraction, folder redesign, scratchpad extend, store backend"scripts/local-release.sgladded
#!/usr/bin/env sigil;;; Run a local Slate CLI build with the release key materialized from pass.;;; The child release process inherits only the temporary key path and public;;; verification policy; private key bytes never enter argv or command tracing.(import (sigil core) (sigil fs) (sigil path) (sigil process) (sigil shell) (sigil string))(define signing-key-entry "sigil/slate/release-signing-v1")(define signing-key-id "slate-cli-release-v1")(define signing-key-fingerprint "SHA256:FwFUpe8UFLB4lzmpysY3SkEwjsDIR21mKjUoaaCqYEA")(define (usage) (display "usage: scripts/local-release.sgl build <version> <dir>\n") (exit 2))(define (quietly thunk) (guard (condition (#t #f)) (thunk)))(define (restore-env! name prior) ;; Sigil currently exposes setenv! but not unsetenv!. An empty value has the ;; same fail-closed behavior as an absent value to scripts/release.sgl. (setenv! name (or prior "")))(define (cleanup-key! key-path key-dir) (when (file-exists? key-path) (quietly (lambda () ($ shred -u ,key-path)))) ;; If shred failed, remove the still-protected file rather than leave secret ;; material behind. The enclosing temporary directory is mode 0700. (when (file-exists? key-path) (quietly (lambda () (delete-file key-path)))) (when (directory-exists? key-dir) (quietly (lambda () (delete-directory key-dir)))))(define (run-build version output-dir) (requires (tools pass chmod ssh-keygen shred)) (let* ((root (path-join (script-dir) "..")) (release-script (path-join root "scripts" "release.sgl")) (allowed-signers (path-join root "release" "allowed_signers")) (key-dir (make-temp-directory)) (key-path (and key-dir (path-join key-dir signing-key-id))) (old-key (getenv "SLATE_RELEASE_SIGNING_KEY")) (old-id (getenv "SLATE_RELEASE_SIGNING_KEY_ID")) (old-allowed (getenv "SLATE_RELEASE_ALLOWED_SIGNERS"))) (unless key-dir (error "could not create a temporary release-key directory")) (dynamic-wind (lambda () #t) (lambda () ($ chmod 700 ,key-dir) ;; `out` captures stdout without displaying it. Do not replace this ;; with `$`: that would print the private key to the release log. ;; `out` trims trailing newlines; OpenSSH's private-key encoding needs ;; exactly one restored after the PEM-style closing marker. (write-file-string key-path (string-append (out pass show ,signing-key-entry) "\n")) ($ chmod 600 ,key-path) (let ((description (out ssh-keygen -lf ,key-path))) (unless (string-contains? description signing-key-fingerprint) (error "pass entry does not match the trusted release-key fingerprint"))) (setenv! "SLATE_RELEASE_SIGNING_KEY" key-path) (setenv! "SLATE_RELEASE_SIGNING_KEY_ID" signing-key-id) (setenv! "SLATE_RELEASE_ALLOWED_SIGNERS" allowed-signers) (display (string-append "Verified release key " signing-key-fingerprint "\n")) ;; process-run inherits the terminal, so the long matrix build remains ;; visible as it progresses. `(sigil shell)` command capture currently ;; buffers a nested command's output until it exits. (let ((status (process-run release-script "build" version output-dir))) (unless (= status 0) (error (string-append "release build exited with status " (number->string status)))))) (lambda () (restore-env! "SLATE_RELEASE_SIGNING_KEY" old-key) (restore-env! "SLATE_RELEASE_SIGNING_KEY_ID" old-id) (restore-env! "SLATE_RELEASE_ALLOWED_SIGNERS" old-allowed) (cleanup-key! key-path key-dir)))))(let ((args (script-args))) (if (and (= (length args) 3) (string=? (car args) "build")) (run-build (cadr args) (caddr args)) (usage)))scripts/release.sgladded
#!/usr/bin/env sigil;;; Build and publish Slate CLI releases. Publishing expects Wrangler's ambient;;; authentication; no account identifiers or credentials live here. Every;;; command has a dry-run form and publishing always targets staging first.(import (sigil core) (sigil fs) (sigil json) (sigil path) (sigil process) (sigil shell) (sigil string))(define platforms '("linux-amd64" "linux-amd64-static" "linux-arm64" "macos-amd64" "macos-arm64" "openbsd-amd64" "openbsd-arm64" "android-arm64" "windows-amd64"))(define cli-version-source "packages/slate-cli/package.sgl")(define release-package-sources '("packages/slate/package.sgl" "packages/slate-cli/package.sgl" "packages/slate-netem/package.sgl" "packages/slate-shell/package.sgl"))(define (say text) (display text) (display "\n"))(define (declared-cli-version) (let* ((marker "\n version: \"") (source (read-file-string cli-version-source)) (parts (string-split source marker))) (unless (= (length parts) 2) (error (string-append "cannot read authoritative CLI version from " cli-version-source))) (car (string-split (cadr parts) "\""))))(define (require-version! requested) (let ((declared (declared-cli-version))) (unless (string=? requested declared) (error (string-append "requested CLI version " requested " does not match declared version " declared)))))(define (require-clean-tracked-checkout!) ;; Deliberately ignore untracked files. They may be unrelated user work. (let ((changes (out git status --porcelain --untracked-files=no))) (unless (= (string-length changes) 0) (error "release builds require a clean tracked checkout"))))(define (require-release-dependencies!) ;; Release commands resolve only checked-in remote locks. Development ;; redirects are deliberately opt-in and must never leak into a manifest. (for-each (lambda (path) (let ((source (read-file-string path))) (unless (string-contains? source "sigil: \"0.20.3\"") (error (string-append "release package does not require Sigil 0.20.3: " path))) (when (or (string-contains? source "from-path") (string-contains? source "commit:")) (error (string-append "release package contains a path or raw commit dependency: " path))))) release-package-sources) (let ((lock (read-file-string "sigil.lock"))) (unless (and (string-contains? lock "name: \"sigil-websocket\"") (string-contains? lock "ref: \"^0.10.8\"") (string-contains? lock "version: \"0.10.8\"") (string-contains? lock "ref: \"^0.20.3\"")) (error "workspace lock does not contain the published release dependency floors")) (when (or (string-contains? lock "from-path") (string-contains? lock "../")) (error "workspace release lock contains a local dependency source"))))(define (artifact-name platform) (string-append "slate-" platform (if (string=? platform "windows-amd64") ".exe" "")))(define (required-env name) (let ((value (getenv name))) (if (and value (> (string-length value) 0)) value (error (string-append name " is required")))))(define (file-sha256 path) (car (string-split (out sha256sum ,path) " ")))(define (copy-artifact! platform out) (let* ((source-name (if (string=? platform "windows-amd64") "slate.exe" "slate")) (source (path-join "build" platform "bin" source-name)) (target (path-join out (artifact-name platform)))) (unless (file-exists? source) (error (string-append "build produced no artifact: " source))) ($ cp ,source ,target)))(define (release-manifest version dir public-base notes-url signing-key-id) (let loop ((rest platforms) (urls #{}) (hashes #{})) (if (null? rest) #{ schema: 1 stream: "cli" latest: version platforms: urls sha256: hashes signature: #{ algorithm: "ed25519" key-id: signing-key-id url: (string-append public-base "/v" version ".json.sig") } release-notes-url: notes-url } (let* ((platform (car rest)) (name (artifact-name platform)) (path (path-join dir name))) (unless (file-exists? path) (error (string-append "missing release artifact: " path))) (loop (cdr rest) (dict-set urls (string->keyword platform) (string-append public-base "/v" version "/" name)) (dict-set hashes (string->keyword platform) (file-sha256 path)))))))(define (write-manifests! version dir) (let* ((base (or (getenv "SLATE_RELEASE_PUBLIC_BASE") "https://useslate.org/releases/cli")) (notes (string-append "https://codeberg.org/sigil/slate/releases/tag/cli-v" version)) (key-id (required-env "SLATE_RELEASE_SIGNING_KEY_ID")) (encoded (string-append (json-encode (release-manifest version dir base notes key-id)) "\n"))) (write-file-string (path-join dir (string-append "v" version ".json")) encoded) (write-file-string (path-join dir "latest.json") encoded)))(define (sign-manifest! version dir) (let ((key (required-env "SLATE_RELEASE_SIGNING_KEY")) (manifest (path-join dir (string-append "v" version ".json"))) (signature (path-join dir (string-append "v" version ".json.sig")))) ;; OpenSSH writes the detached signature beside the input as PATH.sig. ($ ssh-keygen -Y sign -f ,key -n slate-cli-release ,manifest) (unless (file-exists? signature) (error "ssh-keygen produced no detached manifest signature"))))(define (verify-manifest-signature! version dir) (let ((allowed (required-env "SLATE_RELEASE_ALLOWED_SIGNERS")) (identity (required-env "SLATE_RELEASE_SIGNING_KEY_ID")) (manifest (path-join dir (string-append "v" version ".json"))) (signature (path-join dir (string-append "v" version ".json.sig")))) (unless (file-exists? signature) (error "missing detached manifest signature")) ($ ssh-keygen -Y verify -f ,allowed -I ,identity -n slate-cli-release -s ,signature in: ,(read-file-string manifest))))(define (u16-le bytes offset) (+ (bytevector-u8-ref bytes offset) (* 256 (bytevector-u8-ref bytes (+ offset 1)))))(define (u32-le bytes offset) (+ (u16-le bytes offset) (* 65536 (u16-le bytes (+ offset 2)))))(define (bytes-at? bytes offset expected) (and (<= (+ offset (length expected)) (bytevector-length bytes)) (let loop ((rest expected) (i offset)) (or (null? rest) (and (= (car rest) (bytevector-u8-ref bytes i)) (loop (cdr rest) (+ i 1)))))))(define (valid-elf? bytes machine) (and (bytes-at? bytes 0 '(127 69 76 70)) (>= (bytevector-length bytes) 20) (= (u16-le bytes 18) machine)))(define (valid-mach-o? bytes cpu-type) (and (bytes-at? bytes 0 '(207 250 237 254)) (>= (bytevector-length bytes) 8) (= (u32-le bytes 4) cpu-type)))(define (valid-pe-amd64? bytes) (and (bytes-at? bytes 0 '(77 90)) (>= (bytevector-length bytes) 64) (let ((pe-offset (u32-le bytes 60))) (and (<= (+ pe-offset 6) (bytevector-length bytes)) (bytes-at? bytes pe-offset '(80 69 0 0)) (= (u16-le bytes (+ pe-offset 4)) 34404)))))(define (valid-platform-binary? platform bytes) (cond ((string=? platform "windows-amd64") (valid-pe-amd64? bytes)) ((string=? platform "macos-amd64") (valid-mach-o? bytes 16777223)) ((string=? platform "macos-arm64") (valid-mach-o? bytes 16777228)) ((or (string=? platform "linux-amd64") (string=? platform "linux-amd64-static") (string=? platform "openbsd-amd64")) (valid-elf? bytes 62)) (else (valid-elf? bytes 183))))(define (validate-artifact! platform path) (unless (file-exists? path) (error (string-append "missing release artifact: " path))) (unless (valid-platform-binary? platform (read-file-bytes path)) (error (string-append "wrong executable format or architecture for " platform ": " path))))(define (validate-release! version dir) (for-each (lambda (platform) (validate-artifact! platform (path-join dir (artifact-name platform)))) platforms) (let ((versioned (path-join dir (string-append "v" version ".json"))) (latest (path-join dir "latest.json"))) (unless (and (file-exists? versioned) (file-exists? latest)) (error "missing release manifests")) (unless (string=? (read-file-string versioned) (read-file-string latest)) (error "versioned and latest CLI manifests are not byte-identical")) (verify-manifest-signature! version dir)))(define (build! version out) (require-version! version) (require-release-dependencies!) (require-clean-tracked-checkout!) (requires (tools sigil git cp sha256sum ssh-keygen)) (ensure-directory out) (for-each (lambda (platform) (display (string-append "Building " platform "\n")) ($ sigil build slate-cli --config ,platform) (copy-artifact! platform out)) platforms) (write-manifests! version out) (sign-manifest! version out) ($ sha256sum ,@(map (lambda (platform) (path-join out (artifact-name platform))) platforms) out: ,(path-join out "SHA256SUMS")) (validate-release! version out))(define (build-dry-run version out) (require-version! version) (require-release-dependencies!) (say "DRY RUN: no files will be written and no remote calls will be made") (say "preflight: require clean tracked checkout (untracked files ignored)") (say "preflight: published Sigil 0.20.3 and sigil-websocket 0.10.8 dependencies") (say (string-append "preflight: declared CLI version = " version)) (for-each (lambda (platform) (say (string-append "build: sigil build slate-cli --config " platform)) (say (string-append "artifact: " (path-join out (artifact-name platform))))) platforms) (say (string-append "manifest: " (path-join out (string-append "v" version ".json")))) (say (string-append "manifest: " (path-join out "latest.json") " (byte-identical)")) (say (string-append "signature: ed25519 detached " (path-join out (string-append "v" version ".json.sig")))) (say (string-append "checksums: " (path-join out "SHA256SUMS"))))(define (publish-file! bucket key path content-type cache-control) ($ wrangler r2 object put --remote ,(string-append bucket "/" key) --file ,path --content-type ,content-type --cache-control ,cache-control))(define (publication-plan version dir prefix) (let ((version-prefix (string-append prefix "/cli/v" version))) (append (map (lambda (platform) (list (string-append version-prefix "/" (artifact-name platform)) (path-join dir (artifact-name platform)) "application/octet-stream" "public, max-age=31536000, immutable")) platforms) (list (list (string-append prefix "/cli/v" version ".json") (path-join dir (string-append "v" version ".json")) "application/json" "public, max-age=31536000, immutable") (list (string-append prefix "/cli/v" version ".json.sig") (path-join dir (string-append "v" version ".json.sig")) "application/octet-stream" "public, max-age=31536000, immutable") (list (string-append prefix "/cli/latest.json") (path-join dir "latest.json") "application/json" "no-cache")))))(define (print-publication-plan version dir) (require-version! version) (let ((prefix (or (getenv "SLATE_RELEASE_STAGING_PREFIX") "staging"))) (say "DRY RUN: no files will be written and no remote calls will be made") (say "publication order (latest.json is the visibility switch):") (for-each (lambda (item) (say (string-append "PUT " (car item) " <- " (cadr item) " | Content-Type: " (caddr item) " | Cache-Control: " (cadddr item)))) (publication-plan version dir prefix)) (for-each (lambda (platform) (say (string-append "REDIRECT /downloads/cli/latest/" platform " -> /releases/cli/v" version "/" (artifact-name platform)))) platforms)))(define (publish! version dir) (require-version! version) (requires (tools wrangler ssh-keygen)) ;; Validate the complete local set before making any remote mutation. (for-each (lambda (platform) (unless (file-exists? (path-join dir (artifact-name platform))) (error (string-append "missing release artifact: " (artifact-name platform))))) platforms) (validate-release! version dir) (let* ((bucket (required-env "SLATE_RELEASE_BUCKET")) (prefix (or (getenv "SLATE_RELEASE_STAGING_PREFIX") "staging"))) ;; latest.json is deliberately last: it is the visibility switch. (for-each (lambda (item) (publish-file! bucket (car item) (cadr item) (caddr item) (cadddr item))) (publication-plan version dir prefix))))(define (usage) (display "usage: scripts/release.sgl build|build-dry-run|publish|publish-dry-run <version> <dir>\n") (exit 2))(let ((args (cddr (command-line)))) (if (not (= (length args) 3)) (usage) (cond ((string=? (car args) "build") (build! (cadr args) (caddr args))) ((string=? (car args) "build-dry-run") (build-dry-run (cadr args) (caddr args))) ((string=? (car args) "publish") (publish! (cadr args) (caddr args))) ((string=? (car args) "publish-dry-run") (print-publication-plan (cadr args) (caddr args))) (else (usage)))))scripts/run-slate-netemadded
#!/bin/shset -euslate_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)export SIGIL_LOAD_PATH="$slate_root/build/dev/lib${SIGIL_LOAD_PATH:+:$SIGIL_LOAD_PATH}"exec sigil "$slate_root/packages/slate-netem/slate-netem.sgl" "$@"scripts/test-enclave-remote-livemodified
state=$run_dir/statemkdir -p "$state/slate" || harness_setup_failed "cannot create $state/slate"readable=$run_dir/readable.txtimage=$run_dir/preview.pngprintf '%s' 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a4HcAAAAASUVORK5CYII=' | base64 -d >"$image"truncate -s 1600000 "$image"denied=$run_dir/denied.txtprintf 'p3s-readable-marker\n' >"$readable" || harness_setup_failed "cannot write $readable"printf 'p3s-denied-marker\n' >"$denied" || harness_setup_failed "cannot write $denied"node_log_args=()[ -z "${NODE_LOG_LEVEL:-}" ] || node_log_args=(--log-level "$NODE_LOG_LEVEL")XDG_STATE_HOME="$state" \SLATE_NODE_GRANTS="fs:ro:$readable,pty:on,exec:full" \SLATE_NODE_GRANTS="fs:ro:$readable,fs:ro:$image,pty:on,exec:full" \ stdbuf -oL -eL "$slate_bin" node run "${node_log_args[@]+"${node_log_args[@]}"}" >"$node_log" 2>&1 &node_pid=$!deadline=$(( $(date +%s) + READY_TIMEOUT )) P3S_ENCLAVE_WS="ws://127.0.0.1:$ws_port/ws" P3S_ACCOUNT=gate-owner \ P3S_CREDENTIAL="$client_credential" P3S_NODE=gate-node \ P3S_READABLE_PATH="$readable" P3S_DENIED_PATH="$denied" \ P3S_IMAGE_PATH="$image" \ P3S_PTY_MARKER="$marker" P3T_SESSION_SENTINEL="$session_sentinel" \ guix shell binaryen -- sigil test slate --sgl --no-color "$TEST_FILE" ) \ > "$client_log" 2>&1 || rc=$?scripts/test-node-direct-liveadded
#!/usr/bin/env bash# Real two-node direct-dial gate: relay fallback with no advertised endpoint,# then direct success after the same target identity enables its listener.set -euscript_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)slate_root=$(CDPATH= cd -- "$script_dir/.." && pwd). "$script_dir/lib/slate-harness.sh"# Keep the gate pinned to the node-dial implementation worktree by default.# ENCLAVE_ROOT remains available for CI or a differently arranged checkout.enclave_root=${ENCLAVE_ROOT:-$slate_root/.worktrees/enclave-node-dial}slate_bin=${SLATE_BIN:-$slate_root/build/dev/bin/slate}enclave_bin=${ENCLAVE_BIN:-$enclave_root/build/dev/bin/enclave}netem_bin=${NETEM_BIN:-$slate_root/scripts/run-slate-netem}test_file=packages/slate/test/node-direct-live.sglloss_test_file=packages/slate/test/node-network-transition-live.sglcompanion_restart_test_file=packages/slate/test/node-companion-restart-live.sgltarget_restart_test_file=packages/slate/test/node-target-restart-live.sglmulti_root_test_file=packages/slate/test/node-multi-root-live.sglpaired_offline_test_file=packages/slate/test/node-paired-offline-live.sglpaired_offline_reject_test_file=packages/slate/test/node-paired-offline-reject-live.sglpaired_offline_invalidate_test_file=packages/slate/test/node-paired-offline-invalidate-live.sglpaired_offline_multi_root_test_file=packages/slate/test/node-paired-offline-multi-root-live.sglpairing_seed_test_file=packages/slate/test/node-pairing-seed-live.sglloss_scenario=$slate_root/packages/slate-netem/scenarios/node-direct-loss.scntransfer_loss_test_file=packages/slate/test/node-transfer-loss-live.sgltransfer_loss_scenario=$slate_root/packages/slate-netem/scenarios/node-transfer-loss.scnready_timeout=${READY_TIMEOUT:-40}live_timeout=${LIVE_TIMEOUT:-180}# The transition-only spelling names the lifecycle under test. Preserve the# older loss-only switch as an internal compatibility alias for the existing# harness branch conditions.if [ -n "${NODE_NETWORK_TRANSITION_ONLY:-}" ]; then NODE_DIRECT_LOSS_ONLY=1fiharness_say "SLATE-HARNESS: gate node-direct-live"harness_say "SLATE-HARNESS: workspace $slate_root"[ -x "$slate_bin" ] || harness_setup_failed "missing Slate binary: $slate_bin"[ -x "$enclave_bin" ] || harness_setup_failed "missing Enclave binary: $enclave_bin"[ -x "$netem_bin" ] || harness_setup_failed "missing slate-netem binary: $netem_bin"[ -f "$slate_root/$test_file" ] || harness_setup_failed "missing live probe: $test_file"[ -f "$slate_root/$loss_test_file" ] || harness_setup_failed "missing live probe: $loss_test_file"[ -f "$slate_root/$companion_restart_test_file" ] || harness_setup_failed "missing live probe: $companion_restart_test_file"[ -f "$slate_root/$target_restart_test_file" ] || harness_setup_failed "missing live probe: $target_restart_test_file"[ -f "$slate_root/$multi_root_test_file" ] || harness_setup_failed "missing live probe: $multi_root_test_file"[ -f "$slate_root/$paired_offline_test_file" ] || harness_setup_failed "missing live probe: $paired_offline_test_file"[ -f "$slate_root/$paired_offline_reject_test_file" ] || harness_setup_failed "missing live probe: $paired_offline_reject_test_file"[ -f "$slate_root/$paired_offline_invalidate_test_file" ] || harness_setup_failed "missing live probe: $paired_offline_invalidate_test_file"[ -f "$slate_root/$paired_offline_multi_root_test_file" ] || harness_setup_failed "missing live probe: $paired_offline_multi_root_test_file"[ -f "$slate_root/$pairing_seed_test_file" ] || harness_setup_failed "missing live probe: $pairing_seed_test_file"[ -f "$loss_scenario" ] || harness_setup_failed "missing netem scenario: $loss_scenario"[ -f "$slate_root/$transfer_loss_test_file" ] || harness_setup_failed "missing live probe: $transfer_loss_test_file"[ -f "$transfer_loss_scenario" ] || harness_setup_failed "missing netem scenario: $transfer_loss_scenario"harness_require_library_tree "$slate_root" dev# A direct listener intentionally rejects loopback. Select one concrete local# interface address and advertise exactly the address successfully bound.direct_host=${NODE_DIRECT_HOST:-}if [ -z "$direct_host" ]; then direct_host=$(ip -4 -o addr show scope global 2>/dev/null | awk '$2 != "lo" { split($4, a, "/"); if ($2 ~ /^wg/) { print a[1]; found = 1; exit } if (fallback == "") fallback = a[1] } END { if (!found) print fallback }')fi[ -n "$direct_host" ] || harness_setup_failed \ 'no non-loopback IPv4 address is available for the direct listener' \ 'Set NODE_DIRECT_HOST to an explicitly bindable VPN/test-interface address.'harness_say "SLATE-HARNESS: direct bind $direct_host"base_port=${NODE_DIRECT_BASE_PORT:-$((41000 + $$ % 8000))}irc_port=$base_portws_port=$((base_port + 1))caller_port=$((base_port + 2))target_port=$((base_port + 3))direct_port=$((base_port + 4))netem_port=$((base_port + 5))relay_netem_port=$((base_port + 6))target_b_port=$((base_port + 7))target_b_direct_port=$((base_port + 8))run_dir=$(mktemp -d)server_pid= caller_pid= target_pid= target_b_pid= loss_client_pid= netem_pid=kill_child() { local pid=${1:-} [ -n "$pid" ] || return 0 kill "$pid" 2>/dev/null || true local n=0 while kill -0 "$pid" 2>/dev/null && [ "$n" -lt 50 ]; do sleep 0.1 n=$((n + 1)) done kill -9 "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true}cleanup() { kill_child "${loss_client_pid:-}" kill_child "${netem_pid:-}" kill_child "${target_b_pid:-}" kill_child "${target_pid:-}" kill_child "${caller_pid:-}" kill_child "${server_pid:-}" if [ -n "${NODE_DIRECT_KEEP_RUN_DIR:-}" ]; then harness_say "SLATE-HARNESS: preserved $run_dir" else rm -rf "$run_dir" fi}trap cleanup EXITtrap 'cleanup; exit 130' HUP INT TERMdb=$run_dir/enclave.dbcaller_state=$run_dir/caller-statetarget_state=$run_dir/target-statetarget_b_state=$run_dir/target-b-statemkdir -p "$caller_state/slate" "$target_state/slate" "$target_b_state/slate"readable=$run_dir/readable.txtwritable_dir=$run_dir/writablewritable=$writable_dir/live-write.txtrecovery=$writable_dir/transfer-recovery.txtmarker="node-direct-live-$PPID-$$"mkdir -p "$writable_dir"printf '%s\n' "$marker" >"$readable"printf '%s\n' initial >"$writable"printf '%s' relay-recovery >"$recovery"owner=direct-ownercredential=ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddENCLAVE_DB_PATH="$db" "$enclave_bin" user add "$owner" --password "$credential" >/dev/null || harness_setup_failed 'could not seed the live-gate owner'register_bot() { local nick=$1 local out token out=$(ENCLAVE_DB_PATH="$db" "$enclave_bin" bot register "$nick" --owner "$owner") || return 1 token=$(printf '%s\n' "$out" | sed -n 's/.*Token (one-time display): \([0-9a-f]*\).*/\1/p' | head -1) [ "${#token}" -eq 64 ] || return 1 printf '%s' "$token"}caller_token=$(register_bot gate-caller) || harness_setup_failed 'could not register caller node'target_token=$(register_bot gate-target) || harness_setup_failed 'could not register target node'target_b_token=$(register_bot gate-target-b) || harness_setup_failed 'could not register second target node'bot_id() { local nick=$1 id id=$(ENCLAVE_DB_PATH="$db" "$enclave_bin" bot list | sed -n "s/.*bot=$nick id=\([0-9][0-9]*\).*/\1/p" | head -1) [ -n "$id" ] || return 1 printf '%s' "$id"}caller_bot_id=$(bot_id gate-caller) || harness_setup_failed 'could not resolve caller bot id'target_bot_id=$(bot_id gate-target) || harness_setup_failed 'could not resolve target bot id'target_b_bot_id=$(bot_id gate-target-b) || harness_setup_failed 'could not resolve second target bot id'write_identity() { local state=$1 nick=$2 bot_id=$3 token=$4 connect_port=${5:-$ws_port} local stream_port=${6:-$connect_port} printf '{"version":1,"origin":"http://127.0.0.1:%s","websocket-url":"ws://127.0.0.1:%s/ws","stream-websocket-url":"ws://127.0.0.1:%s/ws","node-name":"%s","transport-nick":"%s","bot-id":"%s","token":"%s"}\n' \ "$ws_port" "$connect_port" "$stream_port" "$nick" "$nick" "$bot_id" "$token" \ >"$state/slate/enclave-node.json" chmod 700 "$state/slate" chmod 600 "$state/slate/enclave-node.json"}write_identity "$caller_state" gate-caller "$caller_bot_id" "$caller_token"write_identity "$target_state" gate-target "$target_bot_id" "$target_token"write_identity "$target_b_state" gate-target-b "$target_b_bot_id" "$target_b_token"# Native sigil-websocket does not expose an Origin-header option. Seed a normal# salted token entry for its empty Origin in this disposable state directory;# the daemon still authenticates through the production token verifier.companion_token=flt_node_direct_livecompanion_salt=node-direct-live-saltcompanion_hash=$(printf '%s' "$companion_salt$companion_token" | sha256sum | awk '{print $1}')printf '[{"id":"live-gate","origin":"","label":"native live gate","salt":"%s","token-hash":"%s","created":1,"last-seen":1}]\n' \ "$companion_salt" "$companion_hash" >"$caller_state/slate/origins.json"chmod 600 "$caller_state/slate/origins.json"server_log=$run_dir/server.logcaller_log=$run_dir/caller.logtarget_log=$run_dir/target.logtarget_b_log=$run_dir/target-b.lognode_log_args=()[ -z "${NODE_LOG_LEVEL:-}" ] || node_log_args=(--log-level "$NODE_LOG_LEVEL")ENCLAVE_DB_PATH="$db" ENCLAVE_PORT="$irc_port" ENCLAVE_WS_PORT="$ws_port" \ "$enclave_bin" dev >"$server_log" 2>&1 &server_pid=$!wait_log() { local pid=$1 file=$2 pattern=$3 label=$4 local deadline=$(( $(date +%s) + ready_timeout )) until grep -q "$pattern" "$file" 2>/dev/null; do if ! kill -0 "$pid" 2>/dev/null; then cat "$file" >&2 || true harness_setup_failed "$label exited before readiness" fi if [ "$(date +%s)" -ge "$deadline" ]; then cat "$file" >&2 || true harness_setup_failed "$label was not ready within ${ready_timeout}s" fi sleep 0.05 done}wait_log "$server_pid" "$server_log" 'ws-listener-ready\|WebSocket listening' Enclavewait_health() { local pid=$1 port=$2 label=$3 local deadline=$(( $(date +%s) + ready_timeout )) until curl -fsS "http://127.0.0.1:$port/node/health" >/dev/null 2>&1; do if ! kill -0 "$pid" 2>/dev/null; then harness_setup_failed "$label exited before its local listener was ready" fi if [ "$(date +%s)" -ge "$deadline" ]; then harness_setup_failed "$label local listener was not ready within ${ready_timeout}s" fi sleep 0.05 done}start_caller() { local mutation=${1:-} : >"$caller_log" if [ -n "$mutation" ]; then XDG_STATE_HOME="$caller_state" SLATE_DEV=1 \ SLATE_DIRECT_TEST_MUTATION="$mutation" \ SLATE_NODE_GRANTS="fs:ro:$readable" \ stdbuf -oL -eL "$slate_bin" node run --port "$caller_port" \ --log-level info >"$caller_log" 2>&1 & else XDG_STATE_HOME="$caller_state" SLATE_NODE_GRANTS="fs:ro:$readable" \ stdbuf -oL -eL "$slate_bin" node run --port "$caller_port" \ "${node_log_args[@]+"${node_log_args[@]}"}" \ >"$caller_log" 2>&1 & fi caller_pid=$! wait_log "$caller_pid" "$caller_log" \ 'Slate node connected to Enclave as gate-caller' 'caller node'}start_callerstart_target() { local mode=$1 local target_log_args=() if [ -n "${NODE_DIRECT_CHILD_LOGS:-}" ]; then target_log_args=(--log "$run_dir/target-child.log" --log-level "${NODE_LOG_LEVEL:-info}") fi : >"$target_log" if [ "$mode" = relay-proxied ]; then write_identity "$target_state" gate-target "$target_bot_id" "$target_token" "$ws_port" "$relay_netem_port" else write_identity "$target_state" gate-target "$target_bot_id" "$target_token" "$ws_port" fi if [ "$mode" = direct ] || [ "$mode" = proxied ]; then direct_extra=() if [ "$mode" = proxied ]; then direct_extra=(--direct-advertise-port "$netem_port") fi XDG_STATE_HOME="$target_state" SLATE_DEV=1 \ SLATE_NODE_GRANTS="fs:ro:$readable,fs:rw:$writable_dir,pty:on,exec:full" \ stdbuf -oL -eL "$slate_bin" node run --port "$target_port" \ --direct-host "$direct_host" --direct-port "$direct_port" \ "${direct_extra[@]+"${direct_extra[@]}"}" \ "${node_log_args[@]+"${node_log_args[@]}"}" \ "${target_log_args[@]+"${target_log_args[@]}"}" \ >"$target_log" 2>&1 & else XDG_STATE_HOME="$target_state" SLATE_NODE_GRANTS="fs:ro:$readable,fs:rw:$writable_dir,pty:on,exec:full" \ stdbuf -oL -eL "$slate_bin" node run --port "$target_port" \ "${node_log_args[@]+"${node_log_args[@]}"}" \ "${target_log_args[@]+"${target_log_args[@]}"}" \ >"$target_log" 2>&1 & fi target_pid=$! wait_log "$target_pid" "$target_log" 'Slate node connected to Enclave as gate-target' 'target node' if [ "$mode" = direct ] || [ "$mode" = proxied ]; then advertised_port=$direct_port [ "$mode" = proxied ] && advertised_port=$netem_port grep -q "direct ws : ws://$direct_host:$advertised_port/node/direct" "$target_log" || { cat "$target_log" >&2 harness_setup_failed 'target authenticated but did not publish the requested direct listener' } fi}start_target_b() { local mode=${1:-relay} local direct_args=() if [ "$mode" = direct ]; then direct_args=(--direct-host "$direct_host" --direct-port "$target_b_direct_port") fi : >"$target_b_log" write_identity "$target_b_state" gate-target-b "$target_b_bot_id" "$target_b_token" "$ws_port" XDG_STATE_HOME="$target_b_state" \ SLATE_NODE_GRANTS="fs:ro:$readable,fs:rw:$writable_dir,pty:on,exec:full" \ stdbuf -oL -eL "$slate_bin" node run --port "$target_b_port" \ "${direct_args[@]+"${direct_args[@]}"}" \ "${node_log_args[@]+"${node_log_args[@]}"}" \ >"$target_b_log" 2>&1 & target_b_pid=$! wait_log "$target_b_pid" "$target_b_log" \ 'Slate node connected to Enclave as gate-target-b' 'second target node' if [ "$mode" = direct ]; then grep -q "direct ws : ws://$direct_host:$target_b_direct_port/node/direct" "$target_b_log" || harness_setup_failed 'second target did not publish its direct listener' fi}run_multi_root() { local multi_log=$run_dir/client-multi-root.log rc=0 (cd "$slate_root" && timeout --signal=TERM --kill-after=10 "$live_timeout" \ env PATH="$HOME/.sigil/toolchain/zig:$PATH" \ SLATE_TEST_SRC="$slate_root/packages/slate/src" \ NODE_DIRECT_ENCLAVE_WS="ws://127.0.0.1:$ws_port/ws" \ NODE_DIRECT_ACCOUNT="$owner" NODE_DIRECT_CREDENTIAL="$credential" \ NODE_DIRECT_COMPANION_WS="ws://127.0.0.1:$caller_port/node/ws" \ NODE_DIRECT_COMPANION_TOKEN="$companion_token" \ NODE_MULTI_ROOT_A=gate-target NODE_MULTI_ROOT_B=gate-target-b \ NODE_DIRECT_MARKER="$marker" \ guix shell binaryen -- sigil test slate --sgl --no-color "$multi_root_test_file") \ >"$multi_log" 2>&1 || rc=$? cat "$multi_log" if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then cat "$caller_log" "$target_log" "$target_b_log" "$server_log" >&2 exit "$HARNESS_TIMED_OUT" fi local verdict=0 harness_verdict "$multi_log" 1 || verdict=$? [ "$verdict" -eq "$HARNESS_GREEN" ] || exit "$verdict" harness_say 'SLATE-HARNESS: PASS concurrent direct and relay roots remained isolated'}run_probe() { local expected=$1 target_name=${2:-gate-target} local client_log=$run_dir/client-$target_name-$expected.log rc=0 (cd "$slate_root" && timeout --signal=TERM --kill-after=10 "$live_timeout" \ env PATH="$HOME/.sigil/toolchain/zig:$PATH" \ SLATE_TEST_SRC="$slate_root/packages/slate/src" \ NODE_DIRECT_ENCLAVE_WS="ws://127.0.0.1:$ws_port/ws" \ NODE_DIRECT_ACCOUNT="$owner" NODE_DIRECT_CREDENTIAL="$credential" \ NODE_DIRECT_COMPANION_WS="ws://127.0.0.1:$caller_port/node/ws" \ NODE_DIRECT_COMPANION_TOKEN="$companion_token" \ NODE_DIRECT_TARGET="$target_name" NODE_DIRECT_READABLE="$readable" \ NODE_DIRECT_WRITABLE="$writable" \ NODE_DIRECT_MARKER="$marker" NODE_DIRECT_EXPECT_TRANSPORT="$expected" \ guix shell binaryen -- sigil test slate --sgl --no-color "$test_file") \ >"$client_log" 2>&1 || rc=$? cat "$client_log" if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then cat "$caller_log" "$target_log" "$server_log" >&2 exit "$HARNESS_TIMED_OUT" fi local verdict=0 harness_verdict "$client_log" 1 || verdict=$? if [ "$verdict" -ne "$HARNESS_GREEN" ]; then harness_err '---- caller log ----'; cat "$caller_log" >&2 harness_err '---- target log ----'; cat "$target_log" >&2 harness_err '---- server log ----'; cat "$server_log" >&2 exit "$verdict" fi harness_say "SLATE-HARNESS: PASS real carrier selected $expected"}run_pairing_seed() { local target_name=$1 seed_log=$run_dir/client-seed-$1.log rc=0 (cd "$slate_root" && timeout --signal=TERM --kill-after=10 "$live_timeout" \ env PATH="$HOME/.sigil/toolchain/zig:$PATH" \ SLATE_TEST_SRC="$slate_root/packages/slate/src" \ NODE_DIRECT_ENCLAVE_WS="ws://127.0.0.1:$ws_port/ws" \ NODE_DIRECT_ACCOUNT="$owner" NODE_DIRECT_CREDENTIAL="$credential" \ NODE_DIRECT_COMPANION_WS="ws://127.0.0.1:$caller_port/node/ws" \ NODE_DIRECT_COMPANION_TOKEN="$companion_token" \ NODE_DIRECT_TARGET="$target_name" NODE_DIRECT_READABLE="$readable" \ NODE_DIRECT_MARKER="$marker" \ guix shell binaryen -- sigil test slate --sgl --no-color \ "$pairing_seed_test_file") >"$seed_log" 2>&1 || rc=$? cat "$seed_log" if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then exit "$HARNESS_TIMED_OUT"; fi local verdict=0 harness_verdict "$seed_log" 1 || verdict=$? [ "$verdict" -eq "$HARNESS_GREEN" ] || exit "$verdict"}run_companion_restart() { local restart_flag=$run_dir/companion-restart-ready local session_sentinel=$run_dir/companion-session-sentinel local restart_log=$run_dir/client-companion-restart.log local restart_rc_file=$run_dir/client-companion-restart.rc (cd "$slate_root" && set +e timeout --signal=TERM --kill-after=10 "$live_timeout" \ env PATH="$HOME/.sigil/toolchain/zig:$PATH" \ SLATE_TEST_SRC="$slate_root/packages/slate/src" \ NODE_DIRECT_ENCLAVE_WS="ws://127.0.0.1:$ws_port/ws" \ NODE_DIRECT_ACCOUNT="$owner" NODE_DIRECT_CREDENTIAL="$credential" \ NODE_DIRECT_COMPANION_WS="ws://127.0.0.1:$caller_port/node/ws" \ NODE_DIRECT_COMPANION_TOKEN="$companion_token" \ NODE_DIRECT_TARGET=gate-target NODE_DIRECT_MARKER="$marker" \ NODE_COMPANION_RESTART_FLAG="$restart_flag" \ NODE_DIRECT_SESSION_SENTINEL="$session_sentinel" \ guix shell binaryen -- sigil test slate --sgl --no-color "$companion_restart_test_file" restart_rc=$? printf '%s\n' "$restart_rc" >"$restart_rc_file" exit "$restart_rc") >"$restart_log" 2>&1 & loss_client_pid=$! local deadline=$(( $(date +%s) + ready_timeout )) until [ -f "$restart_flag" ]; do if ! kill -0 "$loss_client_pid" 2>/dev/null; then cat "$restart_log" >&2 || true harness_setup_failed 'companion-restart client exited before arming restart' fi if [ "$(date +%s)" -ge "$deadline" ]; then cat "$restart_log" >&2 || true harness_setup_failed 'companion-restart client did not arm restart' fi sleep 0.02 done kill_child "$caller_pid" caller_pid= sleep 0.2 start_caller wait "$loss_client_pid" 2>/dev/null || true loss_client_pid= cat "$restart_log" local restart_rc restart_rc=$(cat "$restart_rc_file" 2>/dev/null || printf '1') if [ "$restart_rc" -eq 124 ] || [ "$restart_rc" -eq 137 ]; then exit "$HARNESS_TIMED_OUT" fi local restart_verdict=0 harness_verdict "$restart_log" 1 || restart_verdict=$? [ "$restart_verdict" -eq "$HARNESS_GREEN" ] || exit "$restart_verdict" harness_say 'SLATE-HARNESS: PASS companion restart reattached the original PTY'}run_target_restart() { local restart_flag=$run_dir/target-restart-ready local session_sentinel=$run_dir/target-session-sentinel local restart_log=$run_dir/client-target-restart.log local restart_rc_file=$run_dir/client-target-restart.rc (cd "$slate_root" && set +e timeout --signal=TERM --kill-after=10 "$live_timeout" \ env PATH="$HOME/.sigil/toolchain/zig:$PATH" \ SLATE_TEST_SRC="$slate_root/packages/slate/src" \ NODE_DIRECT_ENCLAVE_WS="ws://127.0.0.1:$ws_port/ws" \ NODE_DIRECT_ACCOUNT="$owner" NODE_DIRECT_CREDENTIAL="$credential" \ NODE_DIRECT_COMPANION_WS="ws://127.0.0.1:$caller_port/node/ws" \ NODE_DIRECT_COMPANION_TOKEN="$companion_token" \ NODE_DIRECT_TARGET=gate-target NODE_DIRECT_MARKER="$marker" \ NODE_TARGET_RESTART_FLAG="$restart_flag" \ NODE_DIRECT_SESSION_SENTINEL="$session_sentinel" \ guix shell binaryen -- sigil test slate --sgl --no-color "$target_restart_test_file" restart_rc=$? printf '%s\n' "$restart_rc" >"$restart_rc_file" exit "$restart_rc") >"$restart_log" 2>&1 & loss_client_pid=$! local deadline=$(( $(date +%s) + ready_timeout )) until [ -f "$restart_flag" ]; do if ! kill -0 "$loss_client_pid" 2>/dev/null; then cat "$restart_log" >&2 || true harness_setup_failed 'target-restart client exited before arming restart' fi if [ "$(date +%s)" -ge "$deadline" ]; then cat "$restart_log" >&2 || true harness_setup_failed 'target-restart client did not arm restart' fi sleep 0.02 done kill_child "$target_pid" target_pid= # Holders use die-with-parent. Let that process tree finish and unlink its # control sockets before the replacement node performs its startup sweep; # otherwise it can briefly preserve a socket whose holder is still exiting. sleep 1.5 start_target relay wait "$loss_client_pid" 2>/dev/null || true loss_client_pid= cat "$restart_log" local restart_rc restart_rc=$(cat "$restart_rc_file" 2>/dev/null || printf '1') if [ "$restart_rc" -eq 124 ] || [ "$restart_rc" -eq 137 ]; then exit "$HARNESS_TIMED_OUT" fi local restart_verdict=0 harness_verdict "$restart_log" 1 || restart_verdict=$? [ "$restart_verdict" -eq "$HARNESS_GREEN" ] || exit "$restart_verdict" harness_say 'SLATE-HARNESS: PASS target restart settled the old PTY without replacement' # A new client lifetime proves the replacement target can establish a freshShowing the first 500 of 1032 diff lines for this file. This diff is INCOMPLETE; read the file or clone the repository for the rest.
sigil.lockmodified
;; Auto-generated by sigil deps install. Do not edit.(lock (package name: "sigil-stdlib" url: "codeberg:sigil/sigil" sha: "9fdbd7aff420e65c8c2c73f0f97abf8dbfae2d95" package-selector: "sigil-stdlib" version: "0.22.0") (package name: "sigil-test" url: "codeberg:sigil/sigil" ref: ">=0.20" sha: "9fdbd7aff420e65c8c2c73f0f97abf8dbfae2d95" package-selector: "sigil-test" version: "0.22.0") (package name: "sigil-test-runner" url: "codeberg:sigil/sigil" ref: ">=0.20" sha: "9fdbd7aff420e65c8c2c73f0f97abf8dbfae2d95" package-selector: "sigil-test-runner" version: "0.22.0") (package name: "sigil-wasm-runtime" url: "codeberg:sigil/sigil" ref: "^0.20" sha: "bf1759f72cdb604e4a66123f186d4c7d8c850445" ref: ">=0.20" sha: "6cab4724e4512cb5ff9339ffe6964ed286759d8c" package-selector: "sigil-wasm-runtime" version: "0.20.2") version: "0.22.0") (package name: "sigil-lib" url: "codeberg:sigil/sigil" ref: "^0.19" sha: "bf1759f72cdb604e4a66123f186d4c7d8c850445" ref: ">=0.21" sha: "9fdbd7aff420e65c8c2c73f0f97abf8dbfae2d95" package-selector: "sigil-lib" version: "0.20.2") (package name: "sigil-stdlib" url: "codeberg:sigil/sigil" ref: "^0.20" sha: "bf1759f72cdb604e4a66123f186d4c7d8c850445" package-selector: "sigil-stdlib" version: "0.20.2") version: "0.22.0") (package name: "sigil-format" url: "codeberg:sigil/sigil" ref: "^0.20" sha: "bf1759f72cdb604e4a66123f186d4c7d8c850445" ref: ">=0.20" sha: "6cab4724e4512cb5ff9339ffe6964ed286759d8c" package-selector: "sigil-format" version: "0.20.2") version: "0.22.0") (package name: "sigil-markdown" url: "codeberg:sigil/sigil-markdown" ref: "^0.9.1" version: "0.15.4") (package name: "sigil-wasm-dom" url: "codeberg:sigil/sigil" ref: "^0.20" sha: "bf1759f72cdb604e4a66123f186d4c7d8c850445" ref: ">=0.20" sha: "6cab4724e4512cb5ff9339ffe6964ed286759d8c" package-selector: "sigil-wasm-dom" version: "0.20.2") version: "0.22.0") (package name: "sigil-browser" url: "codeberg:sigil/sigil" ref: "^0.20" sha: "bf1759f72cdb604e4a66123f186d4c7d8c850445" ref: ">=0.20" sha: "6cab4724e4512cb5ff9339ffe6964ed286759d8c" package-selector: "sigil-browser" version: "0.20.2") version: "0.22.0") (package name: "enclave-rpc" url: "codeberg:sigil/enclave" ref: "^0.3" version: "0.3.0") (package name: "sigil-irc" url: "codeberg:sigil/sigil-irc" ref: "^0.16" sha: "3698a4519d11557b2d5f9bc8b752a72f3ec0f387" ref: "^0.16.5" sha: "a3f76b68ed44efe94f49b6abea3c97c71f8dea54" package-selector: "sigil-irc" version: "0.16.4") version: "0.16.5") (package name: "sigil-irc-websocket" url: "codeberg:sigil/sigil-irc" ref: "^0.16" sha: "3698a4519d11557b2d5f9bc8b752a72f3ec0f387" ref: "^0.16.5" sha: "a3f76b68ed44efe94f49b6abea3c97c71f8dea54" package-selector: "sigil-irc-websocket" version: "0.16.4") version: "0.16.5") (package name: "sigil-websocket" url: "codeberg:sigil/sigil-websocket" ref: "^0.10.7" sha: "6c181e52c71a2838980fc376e1e062176ee9dd45" ref: "^0.10.13" sha: "2b0f82a1ecc474ec0af0d8ee8dcaf7befd05e4f5" package-selector: "sigil-websocket" version: "0.10.7") version: "0.10.13") (package name: "sigil-wasm-net" url: "codeberg:sigil/sigil" ref: "^0.20" sha: "bf1759f72cdb604e4a66123f186d4c7d8c850445" ref: ">=0.20" sha: "6cab4724e4512cb5ff9339ffe6964ed286759d8c" package-selector: "sigil-wasm-net" version: "0.20.2") version: "0.22.0") (package name: "sigil-wire" url: "codeberg:sigil/sigil-wire" ref: "^0.1" sha: "980678c6b2a9318135a635c3f92f938e1589c961" package-selector: "sigil-wire" version: "0.1.0") (package name: "sigil-log" url: "codeberg:sigil/sigil-log" ref: "^0.17" sha: "e50644a2d73729cca94f7c6dfd4934f4d8530c21" package-selector: "sigil-log" version: "0.17.0") (package name: "sigil-vt" url: "codeberg:sigil/sigil-vt" ref: "^0.1.0" sha: "9d719e4254bff07f0af46dcba0909758c329f2e1" sha: "c8569cf54c91f4341c36d0e610986b96daa311c5" package-selector: "sigil-vt" version: "0.1.2") version: "0.1.3") (package name: "sigil-doc" url: "codeberg:sigil/sigil-doc" ref: "^0.1.3" sha: "0b7c5caca6a087d8df65e1792f8d2f584511fb56" package-selector: "sigil-doc" version: "0.1.4") (package name: "sigil-test" url: "codeberg:sigil/sigil" ref: "^0.20" sha: "bf1759f72cdb604e4a66123f186d4c7d8c850445" package-selector: "sigil-test" version: "0.20.2") (package name: "sigil-test-runner" url: "codeberg:sigil/sigil" ref: "^0.20" sha: "bf1759f72cdb604e4a66123f186d4c7d8c850445" package-selector: "sigil-test-runner" version: "0.20.2") (package name: "sigil-system" url: "codeberg:sigil/sigil-system" ref: "^0.1" version: "0.17.5") (package name: "sigil-crypto" url: "codeberg:sigil/sigil-crypto" ref: "^0.16" sha: "debba4462b7ff4de143db9e0792b8606fdc455bc" version: "0.16.5") (package name: "sigil-log" url: "codeberg:sigil/sigil-log" ref: "^0.17" sha: "e50644a2d73729cca94f7c6dfd4934f4d8530c21" version: "0.17.0") ref: "^0.16.6" sha: "71362137db0596e0695791b32c4f6e8faf11b39e" version: "0.16.6") (package name: "sigil-tls" url: "codeberg:sigil/sigil-tls" ref: "^0.16.7" sha: "8b1c4658df861b1c31cd7674d9607249ca7d6a27" version: "0.16.7") (package name: "sigil-shell" url: "codeberg:sigil/sigil" ref: ">=0.20" sha: "6cab4724e4512cb5ff9339ffe6964ed286759d8c" package-selector: "sigil-shell" version: "0.22.0") (package name: "lantern" url: "codeberg:sigil/lantern" ref: "^0.1.4" sha: "16cf13897cb48e4cc1eb4065a9969dfbf23d73f6" package-selector: "lantern" version: "0.1.4") (package name: "lantern-system" url: "codeberg:sigil/lantern" ref: "^0.1.4" sha: "16cf13897cb48e4cc1eb4065a9969dfbf23d73f6" package-selector: "lantern-system" version: "0.1.4") (package name: "sigil-tls" url: "codeberg:sigil/sigil-tls" ref: "^0.16" sha: "4cde885f8bbb15b1457b0293a5b9fa7d292cfc46" version: "0.16.6") (package name: "sigil-sqlite" url: "codeberg:sigil/sigil-sqlite" ref: "^0.16.0" sha: "569ef1063dda8f5989968644af31fc09c64b4bfe" version: "0.16.3") (package name: "sigil-sxml" url: "codeberg:sigil/sigil-sxml" ref: "^0.15.0" sha: "5a4f043247600f58bdabae19de55664d759a30c9" version: "0.15.0") (package name: "sigil-sqlite" url: "codeberg:sigil/sigil-sqlite" ref: "^0.16.0" sha: "3d441fb32a216aaf99d0e2732bef5877e91d5d48" version: "0.16.2") (package name: "sigil-nrepl" url: "codeberg:sigil/sigil-nrepl" ref: "^0.17.1" sha: "3d741f14b046ed7a3e39017a3a6d7229be4537a8" version: "0.17.1") sha: "666cb4d09af907e22c5146e74a0b5766ba53a751" version: "0.17.2") (package name: "sigil-ffi" url: "codeberg:sigil/sigil-ffi" ref: "^0.16.3" version: "0.16.3") (package name: "sigil-repl" url: "codeberg:sigil/sigil" ref: "^0.19" sha: "c9719987aa3c866291db10b29044bc21a4790c31" ref: ">=0.19" sha: "6cab4724e4512cb5ff9339ffe6964ed286759d8c" package-selector: "sigil-repl" version: "0.19.4") version: "0.22.0"))