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:
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user