264 lines
9 KiB
Markdown
264 lines
9 KiB
Markdown
|
|
# ncl-sync
|
|||
|
|
|
|||
|
|
Nickel configuration sync daemon for the provisioning platform.
|
|||
|
|
|
|||
|
|
Watches workspace NCL source files, compiles them to JSON via `nickel export`, and maintains a shared file-based cache used by all Nu processes and the `nu_plugin_nickel` plugin.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## What Problem It Solves
|
|||
|
|
|
|||
|
|
Every `prvng` command that reads configuration calls `nickel export --format json` — 124 call sites across the Nu codebase, each taking 2–5 seconds. With ncl-sync, those exports happen once (proactively, in the background) and subsequent calls read from a JSON file in ~3 ms.
|
|||
|
|
|
|||
|
|
| Command | Without ncl-sync | With warm cache |
|
|||
|
|
|---------|-----------------|-----------------|
|
|||
|
|
| `prvng component list` | 3–7 s | ~1.5 s |
|
|||
|
|
| `prvng workflow list` | 3–5 s | ~1.5 s |
|
|||
|
|
| `prvng deploy` | 15–30 s | ~3–5 s |
|
|||
|
|
| `prvng component show` (multi-export) | 12–30 s | ~1.5 s |
|
|||
|
|
|
|||
|
|
The 1.5 s floor is Nu module parse startup — a separate problem.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Architecture
|
|||
|
|
|
|||
|
|
### Workspace-local cache (single writer)
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
ncl-sync daemon (--workspace /ws/libre-daoshi)
|
|||
|
|
│
|
|||
|
|
├── notify watcher → NCL file changed
|
|||
|
|
│ ↓
|
|||
|
|
├── nickel export --format json $path --import-path ...
|
|||
|
|
│ ↓
|
|||
|
|
└── write /ws/libre-daoshi/.ncl-cache/<key>.json ← atomic (tmp → rename)
|
|||
|
|
|
|||
|
|
nu_plugin_nickel (nickel-eval command)
|
|||
|
|
│
|
|||
|
|
├── compute_cache_key(file_content, sorted_imports, "json")
|
|||
|
|
│ ↓
|
|||
|
|
├── resolve cache dir:
|
|||
|
|
│ 1. $NCL_CACHE_DIR (explicit override)
|
|||
|
|
│ 2. walk up from CWD → <workspace>/.ncl-cache/ (workspace-local)
|
|||
|
|
│ 3. ~/.cache/provisioning/config-cache/ (global fallback)
|
|||
|
|
│ ↓
|
|||
|
|
├── does <cache_dir>/<key>.json exist?
|
|||
|
|
│ ├── yes → open file, return Nu record (~3 ms)
|
|||
|
|
│ └── no → nickel export, write to cache, return Nu record (~100 ms)
|
|||
|
|
│
|
|||
|
|
└── Nu script receives typed record — no | from json needed
|
|||
|
|
|
|||
|
|
Nu processes (after C1/C2/C3 migration)
|
|||
|
|
│
|
|||
|
|
└── ncl-eval $path [$ws $prov] → nickel-eval plugin → cache lookup
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Cache location policy
|
|||
|
|
|
|||
|
|
The cache lives **inside the workspace** by default. Three resolution levels, in order:
|
|||
|
|
|
|||
|
|
| Priority | Source | When |
|
|||
|
|
|----------|--------|------|
|
|||
|
|
| 1 | `$NCL_CACHE_DIR` env var | Explicit override (CI, tests, custom setups) |
|
|||
|
|
| 2 | `<workspace>/.ncl-cache/` | Workspace is active (primary case) |
|
|||
|
|
| 3 | `~/.cache/provisioning/config-cache/` | No workspace — shouldn't happen in normal use |
|
|||
|
|
|
|||
|
|
**Why workspace-local:**
|
|||
|
|
- Cache is a derived artifact of the workspace — deleting the workspace deletes the cache
|
|||
|
|
- No cross-workspace pollution when the same NCL filename exists in two workspaces with different import paths
|
|||
|
|
- Per-workspace disk usage is easy to measure
|
|||
|
|
- `.ncl-cache/` is gitignored (derived, regenerable)
|
|||
|
|
|
|||
|
|
**Three components stay aligned on the resolution order:**
|
|||
|
|
- `ncl-sync/src/manifest.rs::resolve_cache_dir()`
|
|||
|
|
- `nu_plugin_nickel/src/helpers.rs::get_cache_dir()`
|
|||
|
|
- `lib_provisioning/config/cache/core.nu::get-cache-base-dir`
|
|||
|
|
|
|||
|
|
### Key derivation (shared between daemon and plugin)
|
|||
|
|
|
|||
|
|
Both ncl-sync and `nu_plugin_nickel` compute the cache key identically:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
key = SHA256(file_content + sorted_import_paths.join(":") + format)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- **Content-based**: same file at a different path → same key. Move/rename don't invalidate the cache.
|
|||
|
|
- **Import-path-aware**: same file with different import paths → different key. Correct when schemas resolve differently.
|
|||
|
|
- **Sort-stable**: `--import-path [$a $b]` and `--import-path [$b $a]` produce the same key.
|
|||
|
|
|
|||
|
|
Verify parity between the two implementations:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
nu tests/cache/test_key_parity.nu
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Manifest
|
|||
|
|
|
|||
|
|
The daemon maintains `<cache_dir>/manifest.json` — a map of `canonical_path → { cache_key, source_mtime, import_paths, cached_at }`. Used to detect stale entries on warm-up and after file changes. Each workspace has its own manifest at `<workspace>/.ncl-cache/manifest.json`.
|
|||
|
|
|
|||
|
|
### Sync requests
|
|||
|
|
|
|||
|
|
After a mutating operation, Nu writes `.sync-<pid>.json` (atomic rename from `.sync-<pid>.tmp`) to the cache dir. The daemon drains these sidecars every 500 ms:
|
|||
|
|
|
|||
|
|
```nushell
|
|||
|
|
# Nu side (lib_provisioning/config/cache/core.nu)
|
|||
|
|
write-sync-request [{ path: $file, import_paths: [$ws $prov] }]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## CLI
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
ncl-sync <SUBCOMMAND>
|
|||
|
|
|
|||
|
|
Subcommands:
|
|||
|
|
daemon Start background daemon (watch + warm + serve sync requests)
|
|||
|
|
warm One-shot: export all NCL in workspace, then exit
|
|||
|
|
invalidate Remove a single file from cache
|
|||
|
|
key Print cache key for (file, import_paths, format)
|
|||
|
|
stats Print cache statistics
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### daemon
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
ncl-sync daemon [--workspace PATH] [--idle-timeout SECS]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- Reads config from `PROVISIONING_CONFIG_DIR/ncl-sync.ncl` (via `platform-config`)
|
|||
|
|
- Cache dir: `<workspace>/.ncl-cache/` (or `$NCL_CACHE_DIR` if set)
|
|||
|
|
- Writes PID to `<cache_dir>/ncl-sync.pid`
|
|||
|
|
- Auto-shuts down after `idle_timeout_secs` (default 600) with no file events or sync requests
|
|||
|
|
- Env override: `NCL_SYNC_LOG=debug ncl-sync daemon` for verbose output
|
|||
|
|
|
|||
|
|
### warm
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
ncl-sync warm /path/to/workspace
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Exports all `.ncl` files under the workspace path, up to `warm_concurrency` parallel processes. Updates manifest. Exits when done.
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
# Warm libre-daoshi before a work session
|
|||
|
|
ncl-sync warm ~/workspaces/libre-daoshi
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### invalidate
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
ncl-sync invalidate /path/to/file.ncl [--workspace PATH]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Removes the manifest entry and the corresponding `.json` cache file. `--workspace` resolves the cache location; if omitted, walks up from CWD looking for a workspace root.
|
|||
|
|
|
|||
|
|
### key
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
ncl-sync key /path/to/file.ncl [--import-path PATH]...
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Prints the SHA256 key. Use to verify daemon–plugin parity:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
sync_key=$(ncl-sync key settings.ncl --import-path $ws --import-path $prov)
|
|||
|
|
nu_key=$(nu -c "use nu_plugin_nickel; nickel-cache-key settings.ncl --import-path [$ws $prov]")
|
|||
|
|
[ "$sync_key" = "$nu_key" ] && echo "parity OK" || echo "PARITY MISMATCH"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### stats
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
ncl-sync stats [--workspace PATH]
|
|||
|
|
# entries: 47
|
|||
|
|
# stale: 0
|
|||
|
|
# cache_dir: /Users/me/workspaces/libre-daoshi/.ncl-cache/
|
|||
|
|
# size: 2.3 MB
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`--workspace` selects which workspace's cache to inspect; if omitted, walks up from CWD.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Configuration
|
|||
|
|
|
|||
|
|
Config file: `provisioning/platform/config/ncl-sync.ncl`
|
|||
|
|
|
|||
|
|
Deploy to `PROVISIONING_CONFIG_DIR` (macOS: `~/Library/Application Support/provisioning/platform/`):
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cp provisioning/platform/config/ncl-sync.ncl \
|
|||
|
|
~/Library/Application\ Support/provisioning/platform/ncl-sync.ncl
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
| Field | Default | Description |
|
|||
|
|
|-------|---------|-------------|
|
|||
|
|
| `cache_dir` | `<workspace>/.ncl-cache` | Override cache location (absolute path; bypasses workspace resolution) |
|
|||
|
|
| `idle_timeout_secs` | `600` | Daemon auto-shutdown after N seconds idle |
|
|||
|
|
| `sync_poll_interval_ms` | `500` | How often to check for `.sync-*.json` sidecars |
|
|||
|
|
| `warm_concurrency` | `4` | Max parallel `nickel export` during warm-up |
|
|||
|
|
| `extra_import_paths` | `[]` | Additional import paths beyond workspace + `$PROVISIONING` |
|
|||
|
|
|
|||
|
|
Env overrides: `NCL_CACHE_DIR` (path), `NCL_SYNC_IDLE_TIMEOUT`, `NCL_SYNC_CONCURRENCY`.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Integration with nu_plugin_nickel
|
|||
|
|
|
|||
|
|
The daemon and plugin are complementary:
|
|||
|
|
|
|||
|
|
| Concern | ncl-sync | nu_plugin_nickel |
|
|||
|
|
|---------|----------|-----------------|
|
|||
|
|
| Cache writer | ✅ primary | ✅ on miss |
|
|||
|
|
| Cache reader | — | ✅ always |
|
|||
|
|
| File watching | ✅ | — |
|
|||
|
|
| Proactive warm-up | ✅ | — |
|
|||
|
|
| Nu value conversion | — | ✅ |
|
|||
|
|
| Fallback on no daemon | — | ✅ (runs nickel directly) |
|
|||
|
|
|
|||
|
|
**Without ncl-sync running**: `nickel-eval` still works — it runs `nickel export` on miss and caches the result itself. Performance degrades to ~100 ms per call (first invocation per file per session).
|
|||
|
|
|
|||
|
|
**With ncl-sync running**: `nickel-eval` hits the daemon-written cache. First call of the session is already ~3 ms because the daemon warmed the cache on `prvng platform start`.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Platform Lifecycle
|
|||
|
|
|
|||
|
|
ncl-sync is managed by the platform service layer:
|
|||
|
|
|
|||
|
|
```nushell
|
|||
|
|
# service-manager.nu
|
|||
|
|
prvng platform start # → ncl-sync-start → nohup ncl-sync daemon &
|
|||
|
|
prvng platform stop # → ncl-sync-stop → kill $(cat ncl-sync.pid)
|
|||
|
|
prvng platform status # → ncl-sync-status → { running, pid }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
To start manually:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
ncl-sync daemon --workspace ~/workspaces/libre-daoshi &
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Build & Install
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd provisioning/platform
|
|||
|
|
cargo build --release --package ncl-sync
|
|||
|
|
install -m 0755 target/release/provisioning-ncl-sync ~/.local/bin/provisioning-ncl-sync
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Design Constraints
|
|||
|
|
|
|||
|
|
- **No platform service dependencies**: no NATS, no SurrealDB, no orchestrator. The daemon configures those services — it cannot depend on them.
|
|||
|
|
- **Single writer**: only ncl-sync writes `.json` cache files. Nu processes write only `.sync-*.json` sidecar signals.
|
|||
|
|
- **Cache miss is safe**: if the daemon is down, `nickel-eval` falls back to direct `nickel export`. No hard dependency.
|
|||
|
|
- **No HTTP/socket**: reads happen via the filesystem. Zero latency overhead, zero coupling.
|
|||
|
|
|
|||
|
|
See [ADR-022](../../adrs/adr-022-ncl-sync-daemon.ncl) for full design rationale.
|