Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

@@ -0,0 +1,101 @@
# `FileWatcher`
## Purpose
`FileWatcher` is the dev-mode file-system watch primitive consumed by
`ReloadDriver`'s §8.3 trigger path. Each `register(plugin, path, trigger)`
call wires a watch against the supplied `FsWatchAdapter`; emitted fs events
are coalesced on a 500ms debounce so a burst of saves (e.g. an editor
performing atomic write-via-rename) collapses to a single reload trigger.
Production deployments disable the watcher entirely by simply not
constructing a `FileWatcher` instance — the constructor is a no-op unless
something is registered.
The watcher is intentionally decoupled from `node:fs/promises` — it
consumes an abstract `FsWatchAdapter` so Chunk-B's tests can drive the
debounce + dispose logic deterministically without ever touching a real
file system. Chunk D wires a Node-backed adapter that calls `fs.watch()`
and forwards events to the registered listener.
## Features
- Debounce coalescing — a burst of fs events collapses to one trigger.
- Configurable debounce window (`debounceMs`, default 500).
- Injectable timer factory (`setTimer` / `clearTimer`) so tests can drive
the time axis deterministically.
- Per-plugin registration with FIFO-style disposer semantics.
- `disposeAll()` tears down every registered watch + cancels pending
triggers — required at host shutdown.
- Throws on duplicate registration so misconfigured plugins fail loudly.
## Usage: Basic
```ts
import { FileWatcher } from "@suaveplan/api-host/hot-reload";
const watcher = new FileWatcher({
fs: {
watch(path, listener) {
const w = fs.watch(path, listener);
return () => w.close();
},
},
});
const dispose = watcher.register(
"@acme/billing",
"/opt/plugins/billing/dist",
async (plugin) => {
await reload.reload(plugin, await readNext(plugin));
}
);
```
## Usage: Advanced
Drive the watcher with a fake clock + an in-memory fs adapter for
deterministic tests:
```ts
let now = 0;
const timers = new Map<number, { cb: () => void; due: number }>();
const watcher = new FileWatcher({
fs: inMemoryFs,
debounceMs: 100,
setTimer: (cb, ms) => {
const handle = Date.now();
timers.set(handle, { cb, due: now + ms });
return handle;
},
clearTimer: (h) => timers.delete(h),
});
watcher.register("p", "/p", trigger);
inMemoryFs.emit("/p");
inMemoryFs.emit("/p");
inMemoryFs.emit("/p");
now += 200;
for (const t of timers.values()) if (t.due <= now) t.cb();
expect(trigger).toHaveBeenCalledTimes(1);
```
## API Reference
- `class FileWatcher`
- `constructor(options: FileWatcherOptions)`
- `register(plugin, path, trigger): () => void` — wire a watch, returns disposer.
- `disposeAll(): void`
- `count(): number`
- `interface FileWatcherOptions` — `fs`, `debounceMs?`, `setTimer?`,
`clearTimer?`.
- `interface FsWatchAdapter` — `watch(path, listener): () => void`.
## Implementation Notes
The watcher refuses duplicate registration for the same plugin name so an
operator misconfiguration surfaces as a synchronous throw rather than a
silently-doubled trigger rate. Disposal is idempotent through the
`handles` map deletion — calling the returned disposer twice is safe.
Pending debounce timers are cancelled on disposal so host shutdown does
not leak a stray trigger after the reload driver has been torn down.

View File

@@ -0,0 +1,154 @@
# `ReloadDriver`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ReloadDriver` implements the §8.1 blue-green hot-reload primitive plus the
§8.1a `RollbackJournal` integration. Each `reload(plugin, opts)` call
captures the live module + manifest into a per-plugin ring buffer, imports
the new module under a cache-busted ESM specifier via the
`@suaveplan/api-loader` `ModuleResolver`, refuses to proceed if native
bindings (`*.node`) are present in the dep graph, stages the new module in
a shadow router slot, drives the lifecycle FSM through `stop → start` against
the new module, runs the health probe, atomically flips the router's
per-plugin version pointer to the new slot, and drains the old slot. On any
failure the driver discards the shadow + auto-transitions the plugin to
`rolled_back` by re-activating the most-recent journal entry.
The driver is paired with `LifecycleDriver` (see `../lifecycle/`): the
lifecycle driver owns the FSM transitions; the reload driver orchestrates
the blue-green plumbing around them. Both share the same `EventBus` so
the `plugin.rolled_back` event is the public boundary between them.
## Features
- Blue-green primitive with atomic shadow → live flip + draining slot.
- 503-window opt-in via `blueGreen: false` for environments that cannot host
two module instances concurrently.
- `RollbackJournal` ring buffer (default depth 3) layered on the in-memory
`createRollbackJournal` from `@suaveplan/api-loader`; persisted subset
written to the plugin ledger via the `RollbackPersistence` adapter.
- Auto-rollback on `start_failed` during reload — the lifecycle driver
is fed `rolled_back` without operator intervention.
- Manual rollback via `rollback(plugin, toIndex)` — Stage 4's admin route
`POST /admin/plugins/{name}/rollback?to=N` calls into this.
- Native-binding guard via `walkNativeBindings()` + `assertNoNativeBindings()`.
- Hydration from the persisted journal store after host restart —
`hydrateFromPersistence(plugin)` re-populates the in-memory ring buffer
from the ledger (the `moduleRef` is intentionally left `null` until a
rollback re-imports).
- `FileWatcher` (sibling feature) supplies dev-mode `--watch` triggers with
a 500ms debounce, gated off in production.
- OTel: `plugin.reload_total{plugin, trigger, outcome}` counter +
`plugin.reload_duration_ms{plugin, trigger}` histogram on every reload.
## Usage: Basic
```ts
import { EventBus } from "@suaveplan/events";
import {
createHostInstruments,
LifecycleDriver,
ReloadDriver,
} from "@suaveplan/api-host";
const reload = new ReloadDriver({
adapters: {
blueGreen,
persistence,
resolver,
health: { async probe() {} },
},
lifecycle,
events,
instruments,
});
reload.recordInitialActivation(plugin, registered, initialHandle);
await reload.reload(plugin, {
manifest: nextManifest,
module: nextModule,
manifestHash: nextHash,
trigger: "admin",
});
```
## Usage: Advanced
Wire the dev-mode file watcher and a custom drain timeout:
```ts
import { FileWatcher, ReloadDriver } from "@suaveplan/api-host/hot-reload";
const reload = new ReloadDriver({
adapters,
lifecycle,
events,
instruments,
drainTimeoutMs: 5_000,
forceTimeoutMs: 10_000,
blueGreen: false, // 503-window mode for native-locks
journalDepth: 5,
});
const watcher = new FileWatcher({ fs: nodeFsWatch });
watcher.register("@acme/billing", "/opt/plugins/billing/dist", async (pluginName) => {
await reload.reload(pluginName, await loadPluginFromDisk(pluginName, "filewatch"));
});
```
Operator-initiated rollback:
```ts
const snap = await reload.rollback("@acme/billing", 0);
// snap.journal[0] is now the active version; emits plugin.rolled_back.
```
## API Reference
- `class ReloadDriver`
- `constructor(options: ReloadDriverOptions)`
- `recordInitialActivation(plugin, registered, handle): void`
- `reload(plugin, opts: ReloadOptions): Promise<ReloadSnapshot>`
- `rollback(plugin, toIndex: number): Promise<ReloadSnapshot>`
- `hydrateFromPersistence(plugin): Promise<void>`
- `snapshot(plugin): ReloadSnapshot`
- `interface ReloadDriverOptions` — `adapters`, `lifecycle`, `events`,
`instruments`, `journalDepth?`, `drainTimeoutMs?`, `forceTimeoutMs?`,
`blueGreen?`, `now?`.
- `interface ReloadAdapters` — `blueGreen`, `persistence`, `resolver`, `health`.
- `interface ReloadOptions` — `manifest`, `module`, `manifestHash`, `trigger`.
- `interface ReloadSnapshot` — `plugin`, `journal`, `inFlight`, `lastReloadAt`,
`lastReloadTrigger`.
- `class FileWatcher` — dev-mode watch primitive with debounced fire.
## Implementation Notes
The `BlueGreenAdapter` interface is intentionally narrow — `stageShadow`,
`flip`, `discardShadow`, `releaseDraining`. The production impl (Stage 4's
`api-router`) backs these with `Atomics.store` on a SharedArrayBuffer; the
Chunk-B reference impl used in tests is a plain map of `{ live, draining }`
records. The flip is "atomic" in the design because at the router level a
single integer write decides which handler runs — but `ReloadDriver` does
not care which mechanism the adapter chooses.
Reinstall criteria (`requiresReinstall(prev, next)`) raises
`LIFECYCLE.REINSTALL_REQUIRED_VERSION_DOWNGRADE` on a downgrade — the
caller propagates that as the reload's failure code. Forward-compatible
upgrades that cross schemaVersion or add new scopes/events/jobs/secrets
intentionally fall through to the lifecycle driver's normal start path so
the host's audit log shows one reload event, not two.
Persistence holds only the serialisable subset; `moduleRef` is a `WeakRef`
that cannot be serialised. On restart the host re-imports the persisted
specifier on demand — the first rollback after a restart pays one extra
`import()` call, every subsequent one is free.
The drain loop currently consults its own `AbortController` but Chunk D
wires the lifecycle driver's `InflightTracker` into the resolver-dispose
path so requests actually settle against the right module — Chunk B
captures the contract + emits the OTel metric so downstream wiring is
mechanical.