diff --git a/api_tests/api_test_restart/conn.mjs b/api_tests/api_test_restart/conn.mjs new file mode 100644 index 000000000..6346330cb --- /dev/null +++ b/api_tests/api_test_restart/conn.mjs @@ -0,0 +1,26 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_restart/conn.mjs + * @Date: 2026-09-21T01:27:16+00:00 (1789954036) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-26 22:30:51 -07:00 (1790487051) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview restart() fixture (#504): the module's top level creates the emitter, so every fresh + * import yields a new one stamped with the import generation. + * @module api_test_restart.conn + */ +import { EventEmitter } from "node:events"; + +const state = (globalThis.__slothletRestartFixture ??= { imports: 0, shutdowns: [] }); + +const conn = new EventEmitter(); +conn.generation = ++state.imports; + +export default conn; diff --git a/api_tests/api_test_restart/counter.mjs b/api_tests/api_test_restart/counter.mjs new file mode 100644 index 000000000..b00564a92 --- /dev/null +++ b/api_tests/api_test_restart/counter.mjs @@ -0,0 +1,36 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_restart/counter.mjs + * @Date: 2026-09-21T01:27:16+00:00 (1789954036) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-26 22:30:51 -07:00 (1790487051) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview restart() fixture (#504): module-scope state that only a fresh import resets. + * @module api_test_restart.counter + */ + +let count = 0; + +/** + * Increment the module-scope counter. + * @returns {number} The new count. + */ +export function increment() { + count += 1; + return count; +} + +/** + * Read the module-scope counter. + * @returns {number} The current count. + */ +export function current() { + return count; +} diff --git a/api_tests/api_test_restart/factory/widget.mjs b/api_tests/api_test_restart/factory/widget.mjs new file mode 100644 index 000000000..144d210e4 --- /dev/null +++ b/api_tests/api_test_restart/factory/widget.mjs @@ -0,0 +1,28 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_restart/factory/widget.mjs + * @Date: 2026-09-21T01:27:16+00:00 (1789954036) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-26 22:30:51 -07:00 (1790487051) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview restart() fixture (#504): a constructible default export stamped with the emitter + * generation of the import that defined it. + * @module api_test_restart.factory.widget + */ + +const state = (globalThis.__slothletRestartFixture ??= { imports: 0, shutdowns: [] }); + +/** + * A constructor function whose instances record the conn generation current at construction. + * @returns {void} + */ +export default function Widget() { + this.generation = state.imports; +} diff --git a/api_tests/api_test_restart/initialize.mjs b/api_tests/api_test_restart/initialize.mjs new file mode 100644 index 000000000..91a423240 --- /dev/null +++ b/api_tests/api_test_restart/initialize.mjs @@ -0,0 +1,26 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_restart/initialize.mjs + * @Date: 2026-09-21T01:27:16+00:00 (1789954036) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-26 22:30:51 -07:00 (1790487051) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview restart() fixture (#504): the user's own root `initialize` (default startup routine). + * @module api_test_restart.initialize + */ + +/** + * Record that the user's root initialize ran. + * @returns {void} + */ +export default function initialize() { + const state = (globalThis.__slothletRestartFixture ??= { imports: 0, shutdowns: [] }); + (state.userInitializes ??= []).push(state.imports); +} diff --git a/api_tests/api_test_restart/probe.mjs b/api_tests/api_test_restart/probe.mjs new file mode 100644 index 000000000..5e0978a4f --- /dev/null +++ b/api_tests/api_test_restart/probe.mjs @@ -0,0 +1,70 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_restart/probe.mjs + * @Date: 2026-09-21T01:27:16+00:00 (1789954036) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-26 22:30:51 -07:00 (1790487051) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview restart() fixture (#504): reads through `self` from inside a leaf, so a test can check + * which instance `self` resolves to, and exposes a slow call for in-flight behavior. + * @module api_test_restart.probe + */ +import { self } from "@cldmv/slothlet/runtime"; + +/** + * The instance ID `self` resolves to. + * @returns {string} `self.slothlet.instanceID`. + */ +export function instanceID() { + return self.slothlet.instanceID; +} + +/** + * The emitter generation `self.conn` resolves to. + * @returns {number} `self.conn.generation`. + */ +export function connGeneration() { + return self.conn.generation; +} + +/** + * Wait, then report the emitter generation captured when the call started. + * @param {number} ms - Delay in milliseconds. + * @returns {Promise} The generation seen at call start. + */ +export async function slow(ms) { + const generation = self.conn.generation; + await new Promise((resolve) => setTimeout(resolve, ms)); + return generation; +} + +/** + * Call the `blocked` leaf as this module (permission tests). + * @returns {string} The leaf's result. + */ +export function callBlocked() { + return self.secret.blocked(); +} + +/** + * Call the `open` leaf as this module (permission tests). + * @returns {string} The leaf's result. + */ +export function callOpen() { + return self.secret.open(); +} + +/** + * Restart the instance from inside this module (permission tests: host-only by default). + * @returns {Promise} The restart result. + */ +export async function restartInstance() { + return self.slothlet.restart(); +} diff --git a/api_tests/api_test_restart/secret.mjs b/api_tests/api_test_restart/secret.mjs new file mode 100644 index 000000000..fa1e36f3f --- /dev/null +++ b/api_tests/api_test_restart/secret.mjs @@ -0,0 +1,33 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_restart/secret.mjs + * @Date: 2026-09-21T01:27:16+00:00 (1789954036) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-26 22:30:51 -07:00 (1790487051) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview restart() fixture (#504): permission targets. + * @module api_test_restart.secret + */ + +/** + * Target of a config-declared deny rule. + * @returns {string} "blocked". + */ +export function blocked() { + return "blocked"; +} + +/** + * Target of a runtime-added deny rule. + * @returns {string} "open". + */ +export function open() { + return "open"; +} diff --git a/api_tests/api_test_restart/shutdown.mjs b/api_tests/api_test_restart/shutdown.mjs new file mode 100644 index 000000000..a32bc74f7 --- /dev/null +++ b/api_tests/api_test_restart/shutdown.mjs @@ -0,0 +1,27 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_restart/shutdown.mjs + * @Date: 2026-09-21T01:27:16+00:00 (1789954036) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-26 22:30:51 -07:00 (1790487051) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview restart() fixture (#504): the user's own root `shutdown`, which a restart's teardown + * must run — also when the restart was triggered by a module. + * @module api_test_restart.shutdown + */ + +/** + * Record that the user's root shutdown ran. + * @returns {void} + */ +export default function shutdown() { + const state = (globalThis.__slothletRestartFixture ??= { imports: 0, shutdowns: [] }); + (state.userShutdowns ??= []).push(state.imports); +} diff --git a/api_tests/api_test_restart/tracker.mjs b/api_tests/api_test_restart/tracker.mjs new file mode 100644 index 000000000..676533225 --- /dev/null +++ b/api_tests/api_test_restart/tracker.mjs @@ -0,0 +1,30 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_restart/tracker.mjs + * @Date: 2026-09-21T01:27:16+00:00 (1789954036) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-26 22:30:51 -07:00 (1790487051) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview restart() fixture (#504): a shutdown routine contributor that records which import + * generation was torn down. + * @module api_test_restart.tracker + */ + +const state = (globalThis.__slothletRestartFixture ??= { imports: 0, shutdowns: [] }); +const generation = (state.trackerImports = (state.trackerImports ?? 0) + 1); + +/** + * Shutdown routine: record this module's generation. + * @returns {void} + */ +export function shutdown() { + state.shutdowns.push(generation); + if (state.throwOnShutdown) throw new Error("tracker shutdown failure"); +} diff --git a/api_tests/api_test_restart_extra/plugin.mjs b/api_tests/api_test_restart_extra/plugin.mjs new file mode 100644 index 000000000..1dbdf97ac --- /dev/null +++ b/api_tests/api_test_restart_extra/plugin.mjs @@ -0,0 +1,24 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_restart_extra/plugin.mjs + * @Date: 2026-09-21T01:27:16+00:00 (1789954036) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-26 22:30:51 -07:00 (1790487051) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview restart() fixture (#504): mounted at runtime with api.slothlet.api.add(). + * @module api_test_restart_extra.plugin + */ + +/** + * @returns {string} "plugin". + */ +export function ping() { + return "plugin"; +} diff --git a/docs/LIFECYCLE.md b/docs/LIFECYCLE.md index ce5083c3b..5cbbd8176 100644 --- a/docs/LIFECYCLE.md +++ b/docs/LIFECYCLE.md @@ -216,6 +216,53 @@ Emitted when all lazy-mode modules have been materialized. Requires `tracking: { --- +## Instance Events + +Four events mark an instance coming up, going down, and being replaced by [`api.slothlet.restart()`](RELOAD.md#apislothletrestart). A restart emits all four, in this order: + +| Order | Event | Emitted on | Fires when | +| ----- | ----------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------- | +| 1 | `restart` | old instance | `restart()` begins — before anything is torn down | +| 2 | `shutdown` | old instance | internal teardown begins (after `mode: "shutdown"` routines and the root `shutdown` hook have run), while the api is still up | +| 3 | `init` | new instance | the new instance has finished building, including `mode: "startup"` routines | +| 4 | `restarted` | new instance | the new instance is live behind the same `api` reference and held references have been re-pointed at it | + +`shutdown` and `init` are not restart-specific: `shutdown` fires on every teardown (`api.shutdown()`, `api.slothlet.shutdown()`, `api.destroy()`), and `init` fires when `slothlet()` finishes the cold start. A `reload()` is the same instance re-reading its modules, so it emits neither. + +**Event data:** + +```javascript +// restart +{ instanceID: "slothlet_…" } // the instance being replaced +// shutdown +{ instanceID: "slothlet_…", restart: true } // restart: false for a plain shutdown +// init +{ instanceID: "slothlet_…", restart: true } // restart: false for the cold start +// restarted +{ instanceID: "slothlet_…", previousInstanceID: "slothlet_…" } +``` + +**Who receives them.** Each event reaches the subscribers of the instance it is emitted on. A restart builds the new instance from the original config and carries nothing runtime over, so: + +- Handlers declared in the [`lifecycle` config option](#construction-time-subscription-lifecycle-config-option) receive **all four** — they are subscribed on the old instance and subscribed again on the new one, because the new one is built from the same config. The same handlers receive `init` at the cold start too. +- Handlers added at runtime with `api.slothlet.lifecycle.on(...)` belong to the old instance: they receive `restart` and `shutdown`, then are dropped with it. They never see `init` or `restarted`, nor any later event. + +To act once a restart has completed, either declare a `restarted` (or `init`) handler in the `lifecycle` config option, or simply await the call — `await api.slothlet.restart()` resolves after `restarted` has been emitted. + +```javascript +const api = await slothlet({ + base: "./api", + lifecycle: { + restart: ({ instanceID }) => log(`restarting ${instanceID}`), + restarted: ({ instanceID, previousInstanceID }) => log(`${previousInstanceID} → ${instanceID}`) + } +}); + +await api.slothlet.restart(); +``` + +--- + ## Module Discovery Events These fire from the module discovery + mount pipeline at `api.slothlet.api.modules.*` (see the dedicated module discovery docs for the full surface). They observe both the discovery phase and the per-module mount phase. diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index c610e29ce..de6c38576 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -803,7 +803,22 @@ To allow a trusted module to toggle permissions, add a more specific allow rule: | `control.seal()` | One-way lock (v3.12.0+). Freezes the policy: after sealing, `enable`, `disable`, `addRule`, `removeRule`, `readGating`, and `principal.register` / `principal.unregister` throw `PERMISSION_SEALED`. Idempotent; there is no unseal. Enforcement keeps running, `principal.invalidate` keeps working, and `shutdown()` is never blocked. | | `control.sealed` | Accessor — whether the control surface has been sealed (`boolean`). | -**Sealing the policy.** `control.seal()` locks the permission policy so it cannot be mutated again for the life of the instance — useful once a host has finished wiring rules and wants to guarantee no later code (including a rule-managing leaf) can widen access. Only the host or an explicitly-allowed module can call it, since `control.**` is deny-by-default for modules. The seal is preserved across `reload()`. It never blocks `shutdown()`, so teardown always works, and it does not change enforcement — sealed or not, rules evaluate the same. +**Sealing the policy.** `control.seal()` locks the permission policy so it cannot be mutated again for the life of the instance — useful once a host has finished wiring rules and wants to guarantee no later code (including a rule-managing leaf) can widen access. Only the host or an explicitly-allowed module can call it, since `control.**` is deny-by-default for modules. The seal is preserved across `reload()`. [`restart()`](RELOAD.md#apislothletrestart) is different: it rebuilds the instance from its original config, so the new instance has only the config's rules and is **not sealed** — seal it again afterwards if needed. The seal never blocks `shutdown()`, so teardown always works, and it does not change enforcement — sealed or not, rules evaluate the same. + +### `restart` — Host-Only by Default + +[`api.slothlet.restart()`](RELOAD.md#apislothletrestart) rebuilds the instance from its original config and discards every runtime rule, event rule, principal and the seal. A module that could call it could undo the host's runtime policy, so a built-in rule denies it to every module: + +```javascript +// Built-in rule registered for every instance: +{ caller: "**", target: "slothlet.restart", effect: "deny" } +``` + +Like every built-in rule, it is enforced only when the instance has a `permissions` config. With no `permissions` block the permission system is off entirely, so modules can call `restart()`; configure `permissions` (even just `{ defaultPolicy: "allow" }`) to make it host-only. The host is never gated. To let a trusted module restart the instance, add an instance rule on the same exact target — equal specificity, higher layer, so it outranks the built-in (this holds under both `defaultPolicy: "allow"` and `"deny"`): + +```javascript +{ caller: "admin.**", target: "slothlet.restart", effect: "allow" } +``` ### Lifecycle methods and default routines — Host-Only diff --git a/docs/RELOAD.md b/docs/RELOAD.md index 2a85a9039..e73861905 100644 --- a/docs/RELOAD.md +++ b/docs/RELOAD.md @@ -1,6 +1,6 @@ # Runtime API Mutations & Reload -Slothlet supports three runtime mutation operations - `add`, `remove`, and `reload` - that let you evolve the API surface after initialization without destroying existing references. All three share a common proxy-based reference preservation system so callers never need to re-acquire the API object. +Slothlet supports three runtime mutation operations - `add`, `remove`, and `reload` - that let you evolve the API surface after initialization without destroying existing references. All three share a common proxy-based reference preservation system so callers never need to re-acquire the API object. For the opposite of a reload — throwing away everything that happened at runtime — see [`api.slothlet.restart()`](#apislothletrestart). --- @@ -16,6 +16,7 @@ Slothlet supports three runtime mutation operations - `add`, `remove`, and `relo - [ESM / CJS Cache Busting](#esm--cjs-cache-busting) - [Operation History and Replay](#operation-history-and-replay) - [Module Ownership (moduleID)](#module-ownership-moduleid) +- [api.slothlet.restart()](#apislothletrestart) - [Lifecycle Events](#lifecycle-events) --- @@ -397,6 +398,97 @@ See [CONFIGURATION.md](CONFIGURATION.md#the-apislothletdiag-namespace) for the f --- +## `api.slothlet.restart()` + +`reload()` is deliberately **not** a clean slate: it keeps references, replays the runtime `add()` / `remove()` history, and keeps hooks, runtime permission rules, metadata and runtime assignments. `restart()` is the opposite — it behaves exactly as if the instance were created again from the `slothlet({...})` call that made it, but behind the same `api` reference. + +```javascript +const api = await slothlet({ base: "./api" }); + +api.conn.handlers = { onSend }; // runtime assignment +await api.slothlet.api.add("plugins", "./plugins"); + +await api.slothlet.api.reload(); // api.conn.handlers and api.plugins are still there +await api.slothlet.restart(); // both gone — the instance is back to its original config +``` + +A restart: + +1. emits `restart` on the old instance, +2. shuts the old instance down through the normal teardown path — `mode: "shutdown"` routines, the root `shutdown` hook, then the internal teardown (emits `shutdown`; clears the context store, ownership, the permission / version / event managers, the EventEmitter and scheduler patches it holds, and the TypeScript transform cache), +3. builds a **new instance** from the **original config** under a new instance ID — every module is imported fresh (cache-busted), so module-scope state starts over — and emits `init` on it, +4. swaps it in behind the existing `api` root proxy, re-points held references, and emits `restarted`. + +See [LIFECYCLE.md](LIFECYCLE.md#instance-events) for the four events and who receives them. + +### Reload vs restart + +| | full reload (`api.slothlet.reload()`) | `restart()` | +| ------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- | +| Rebuilds from | the current config | the **original** `slothlet({...})` config | +| Module-scope state | fresh (modules re-imported) | fresh (modules re-imported) | +| Runtime `add()` / `remove()` history | replayed | **dropped** | +| Runtime hooks (`hook.on`) | kept | **dropped** | +| Runtime permission rules, event rules, principals | replayed | **dropped** (config-declared rules re-applied) | +| Runtime event subscriptions (`event.on`) | dropped | **dropped** | +| Runtime lifecycle subscribers (`lifecycle.on`) | dropped | **dropped** (config `lifecycle` handlers re-subscribed) | +| User metadata (`metadata.set` / `setGlobal`) | kept | **dropped** (config `metadata` re-applied) | +| Runtime assignments (`api.x = …`, `self.x = …`) | cleared (a base `api.slothlet.api.reload()` keeps them) | **dropped** | +| `control.seal()` | preserved | **dropped** — the new instance starts unsealed | +| `api.slothlet.env` snapshot | kept | re-captured (a new instance) | +| Instance ID | rotated (`_reload_`) | new | +| `api` reference | preserved | preserved | +| Held child references (`const conn = api.conn`) | see [Reference Preservation](#reference-preservation) | re-pointed to the new instance's node at the same path | +| Lifecycle events | `impl:changed` per replaced leaf | `restart` → `shutdown` → `init` → `restarted` | +| Gated by | `api.mutations.reload` | `api.mutations.reload` | + +### The original config + +The config object passed to `slothlet()` is snapshotted when the instance is created: plain objects and arrays are copied (and the copy frozen), so mutating your own options object afterwards does not change what a restart rebuilds from. Functions, class instances and other non-plain values are kept as passed, and the top-level `reference` and `context` objects are kept by identity — they are your live objects, handed to the new instance just as the original call handed them over. A relative `base` stays resolved against the file that made the original `slothlet()` call, not against the caller of `restart()`. + +### Held references + +The `api` root keeps working unchanged. A reference to anything below it — `const conn = api.conn`, `const add = api.math.add` — is re-pointed: from the moment the swap completes, every operation on it (property reads and writes, calls, `in`, key enumeration) is forwarded to the node at the **same path** in the new instance, resolved afresh on each use, so the reference keeps following any number of restarts. The resolution is ordinary property access from the root, with the same lazy materialization and the same read-level permission gate as re-reading `api.` would have. + +If the path no longer exists after the restart — typically a mount made at runtime with `add()`, which a restart does not replay — using the reference throws `RESTART_REFERENCE_UNRESOLVED`: + +```javascript +await api.slothlet.api.add("plugin", "./plugin"); +const plugin = api.plugin; + +await api.slothlet.restart(); +plugin.ping(); // throws SlothletError RESTART_REFERENCE_UNRESOLVED ('plugin' no longer exists) +``` + +A held reference keeps the `typeof` it had when it was taken (a proxy's callability is fixed at creation), so a path that changed from a namespace object to a function across the restart is best re-read from `api`. + +### Calls in flight and concurrent restarts + +`restart()` does not wait for calls that are already running, and does not reject them: as with `reload()`, a call that began before the restart finishes on the old implementation (only in-flight lazy materializations are drained, by the teardown). A `self` lookup inside such a call reaches the new instance once the swap is complete. Calling `restart()` again while one is in progress returns the same promise — the two calls share one restart. + +### Errors + +- A throwing `mode: "shutdown"` routine or root `shutdown` hook does not stop the restart; the old instance is still torn down, the new one is built, and the error is re-thrown once `restarted` has been emitted. +- If building the new instance fails (for example a module that no longer parses), the old instance is already shut down and the error propagates; fix the cause and call `restart()` again — the original config is still retained. +- `restart()` throws `INVALID_CONFIG_MUTATIONS_DISABLED` when `api.mutations.reload` is `false`. + +### Permissions and the seal + +A restart is a fresh `slothlet({...})` call, and that includes the permission policy: the new instance has **exactly the rules declared in the original config** — every rule, event rule and principal added at runtime is gone — and it is **not sealed**, even if the old instance was. If the host relies on `control.seal()`, it seals the new instance again after the restart (for example from a config-declared `restarted` lifecycle handler, or after `await api.slothlet.restart()`). + +Because a restart discards the runtime policy, **modules cannot call it by default**: a built-in rule denies `slothlet.restart` to every module, the same way `slothlet.permissions.control.**` is protected. Like every built-in rule, it applies only when the instance has a `permissions` config: with no `permissions` block the permission system is off, and modules can call `restart()` (as they can `reload()` and `shutdown()`). The host is never gated. To let a trusted module restart the instance, add an instance rule on the same exact target — it outranks the built-in: + +```javascript +const api = await slothlet({ + base: "./api", + permissions: { rules: [{ caller: "admin.**", target: "slothlet.restart", effect: "allow" }] } +}); +``` + +See [PERMISSIONS.md](PERMISSIONS.md#restart--host-only-by-default). + +--- + ## Lifecycle Events The lifecycle system emits events for each wrapper impl change during mutations. Subscribe via `api.slothlet.lifecycle`: diff --git a/docs/generated/API.md b/docs/generated/API.md index ca37fdcdc..45e04ed91 100644 --- a/docs/generated/API.md +++ b/docs/generated/API.md @@ -247,7 +247,7 @@ Only relevant when modules are registered via api.slothlet.api.add()[debug] | boolean \| object | false | Enable verbose internal logging. `true` enables all categories. Pass an object with sub-keys `builder`, `api`, `index`, `modes`, `wrapper`, `ownership`, `context` to target specific subsystems. | | [silent] | boolean | false | Suppress all console output from slothlet (warnings, deprecations). Does not affect `debug`. | | [diagnostics] | boolean | false | Enable the `api.slothlet.diag.*` introspection namespace. Intended for testing; do not enable in production. | -| [lifecycle] | Object.)> | | Construction-time lifecycle subscribers, registered on the lifecycle emitter BEFORE the api builds so events emitted during cold-start `buildAPI` (init-time `impl:warning` / `impl:created` / …) are observable. Maps an event name to a handler `function(data, token)` or an array of them; any event name is accepted. Because they are ordinary subscribers, they also receive runtime events afterward — equivalent to calling `api.slothlet.lifecycle.on(event, fn)` for each, but early enough to catch initialization diagnostics. Example: `{ "impl:warning": (d) => log(d), "impl:error": [onError, audit] }`. | +| [lifecycle] | Object.)> | | Construction-time lifecycle subscribers, registered on the lifecycle emitter BEFORE the api builds so events emitted during cold-start `buildAPI` (init-time `impl:warning` / `impl:created` / …) are observable. Maps an event name to a handler `function(data, token)` or an array of them; any event name is accepted. Because they are ordinary subscribers, they also receive runtime events afterward — equivalent to calling `api.slothlet.lifecycle.on(event, fn)` for each, but early enough to catch initialization diagnostics. Because they come from the config, `api.slothlet.restart()` subscribes them again on the new instance, so they also receive the new instance's `init` and `restarted` events (runtime `lifecycle.on` subscribers are dropped with the old instance). Example: `{ "impl:warning": (d) => log(d), "impl:error": [onError, audit] }`. | | [collectLifecycleHooks] | boolean | false | DEPRECATED — will be removed in v4. Expands into two implicit `routines` entries (`{name: "^**.shutdown", mode: "shutdown", order: "depth"}` and the `destroy` equivalent) reproducing this option's original whole-tree, cross-mount, deepest-first scope for literally-named `shutdown`/`destroy` leaves, dropping any existing `shutdown`/`destroy`-mode routine (including the built-in `shutdown` default) in favor of these — and sets the effective `autoRoutines` to `true` unless `autoRoutines` is given explicitly. Nested hooks remain directly callable regardless. Emits a `V3_CONFIG_DEPRECATED` warning unless `silent: true`. | | [routines] | Array.<(string|{name: string, mode: ("manual"|"startup"|"shutdown"|"destroy"), recursive: boolean, order: ("mount"|"depth"), cascade: boolean})> | | Stackable lifecycle routines (#341). Every mounted module exporting a function matching a configured routine name is composed into one callable at its exact composed api path, plus a root cascade (`self.()`, i.e. `api.()`) that runs every matching contribution anywhere. Entries: `"name"` (mode `"manual"`), `"name:mode"`, or `{ name, mode?, recursive?, order?, cascade? }` (`recursive`/`order`/`cascade` only settable via the object form). `name` is mount-relative by default (a bare name matches only a mount's own top level; a dotted name matches a fixed relative sub-path, or with `recursive: true` any depth within the mount); a `^`-prefixed name is root-anchored, matched via glob (`*`, `**`, `{}`, `!`) against the full api path, crossing mount boundaries. `order` (`"mount"` \| `"depth"`, mode-defaulted) controls the root cascade's grouping order. `cascade` (default `true`) controls whether the root `api.()` run-all cascade is created at all — set `cascade: false` (#400) for a per-entity lifecycle routine, where the host must invoke exactly one co-owner by key via `api...for(moduleID)(...)` rather than a run-all; every stacked path also exposes `api...contributors` (the moduleIDs present there). `.for(key)` runs that one contributor in its own extent/identity with the args passed straight through, and bypasses the `stackRoutines` owner-filter so a specific co-owner runs even if it lost the shared-path collision. Providing `routines` at all REPLACES the built-in defaults (`slothlet.defaults.routines`: `initialize` → `startup`, `shutdown` → `shutdown`) — spread `slothlet.defaults.routines` to extend them instead, or pass `[]` to disable every routine. Every configured routine is always wrapped and directly callable regardless of `autoRoutines`. Whether two or more contributors colliding at the identical api path all run is governed by `stackRoutines` (#365), independent of `collisionMode` — by default only the single contribution that actually owns that path runs, matching ordinary collision behavior. A throwing contributor doesn't stop the chain — every contributor runs (best-effort), and one aggregate `ROUTINE_FAILED` error is thrown afterward if any failed. See LIFECYCLE.md. | | [autoRoutines] | boolean | false | The non-deprecated replacement for `collectLifecycleHooks`. TEMPORARY v3-compat default (#341): `false` for now, so a project upgrading sees no behavior change from a pre-existing nested leaf that happens to share a routine's name (e.g. `shutdown`) — it stays stacked and directly callable, but does not start auto-firing. When `true`, every `mode: "startup"` routine's cascade runs at the end of compose, and every `mode: "shutdown"`/`"destroy"` routine's cascade runs on the corresponding dispose call. Planned to default to `true` in v4 (`collectLifecycleHooks` removed at the same time) — see LIFECYCLE.md. | diff --git a/src/lib/builders/api_builder.mjs b/src/lib/builders/api_builder.mjs index 4b3747ef0..3d17fa0d3 100644 --- a/src/lib/builders/api_builder.mjs +++ b/src/lib/builders/api_builder.mjs @@ -2387,6 +2387,22 @@ export class ApiBuilder extends ComponentBase { return slothlet.reload(options); }, + /** + * Clean-slate restart (#504): shut this instance down and build a new one from the original + * `slothlet({...})` config behind the same `api` reference. Nothing runtime is carried over. + * Gated by `api.mutations.reload`, like `reload()` — it re-reads every module from disk. + * @returns {Promise} The same bound API reference. + */ + restart: async () => { + if (!config.api?.mutations?.reload) { + throw new slothlet.SlothletError("INVALID_CONFIG_MUTATIONS_DISABLED", { + operation: "restart", + validationError: true + }); + } + return slothlet.restart(); + }, + /** * Shutdown instance and cleanup resources * @returns {Promise} diff --git a/src/lib/handlers/permission-manager.mjs b/src/lib/handlers/permission-manager.mjs index 36dc70f0a..c0d78112d 100644 --- a/src/lib/handlers/permission-manager.mjs +++ b/src/lib/handlers/permission-manager.mjs @@ -407,6 +407,13 @@ export class PermissionManager extends ComponentBase { this.addRule({ caller: "**", target: "slothlet.reload", effect: "deny" }, "__builtin__"); this.addRule({ caller: "**", target: "slothlet.shutdown", effect: "deny" }, "__builtin__"); + // Built-in deny rule (#504): `slothlet.restart` rebuilds the whole instance from its original + // config, discarding every runtime rule, principal, subscription and the permission seal — so a + // module that could call it could undo the host's runtime policy. Host-only by default; an + // instance rule on the same exact target outranks this one, which is how the host grants it to a + // trusted module (`{ caller: "admin.**", target: "slothlet.restart", effect: "allow" }`). + this.addRule({ caller: "**", target: "slothlet.restart", effect: "deny" }, "__builtin__"); + // Built-in allow rules: the caller-identity utilities `slothlet.lockCaller` and // `slothlet.bind` grant no security-sensitive access — they only pin a callback's // caller identity, which strengthens enforcement. Allowing them by default means a diff --git a/src/lib/handlers/unified-wrapper.mjs b/src/lib/handlers/unified-wrapper.mjs index 3a8f4b707..59c4245c3 100644 --- a/src/lib/handlers/unified-wrapper.mjs +++ b/src/lib/handlers/unified-wrapper.mjs @@ -1018,6 +1018,148 @@ function createNamedProxyTarget(nameHint, fallback) { */ const _proxyRegistry = new WeakMap(); +/** + * Whether a wrapper belongs to an api tree that a restart() has since replaced (#504). + * + * @param {UnifiedWrapper} wrapper - Wrapper to test. + * @returns {boolean} True when a newer tree is live and this wrapper's traps must forward to it. + * @private + * + * @example + * if (runtime_isRestartStale(wrapper)) return forwardToLiveTree(); + */ +function runtime_isRestartStale(wrapper) { + return wrapper.____slothletInternal.epoch < wrapper.____slothlet._liveEpoch; +} + +/** + * Resolve the node at a stale wrapper's path (plus an optional property chain below it, for a + * waiting proxy) in the LIVE api tree after a restart() (#504). + * + * @description + * The walk is ordinary property access from the live root, so it behaves exactly as re-reading + * `api.` would — the same lazy materialization and the same read-level permission gate for + * whoever holds the reference. The path is re-resolved on every use, so a held reference follows any + * number of restarts. A path that no longer exists throws `RESTART_REFERENCE_UNRESOLVED`. + * + * @param {UnifiedWrapper} wrapper - The stale wrapper. + * @param {string[]} [propChain=[]] - Property chain below the wrapper (waiting proxies). + * @returns {unknown} The live node. + * @throws {SlothletError} RESTART_REFERENCE_UNRESOLVED when the path is gone after the restart. + * @private + * + * @example + * const live = runtime_resolveRestartTarget(wrapper, ["handlers"]); + */ +function runtime_resolveRestartTarget(wrapper, propChain = []) { + const apiPath = wrapper.____slothletInternal.apiPath; + const segments = [...(apiPath ? String(apiPath).split(".") : []), ...propChain.map(String)]; + const fail = () => { + throw new wrapper.SlothletError("RESTART_REFERENCE_UNRESOLVED", { apiPath: segments.join(".") }, null, { validationError: true }); + }; + let node = wrapper.____slothlet.api; + for (const segment of segments) { + if (node === null || node === undefined) fail(); + node = node[segment]; + } + if (node === null || node === undefined) fail(); + return node; +} + +/** + * Map a `this` argument that is itself a stale (restart-replaced) proxy to its live counterpart, so + * a method reached through a held reference runs against the live node rather than the old one. + * + * @param {unknown} value - The `this` argument (or `new.target`). + * @returns {unknown} The live node when `value` is a stale wrapper proxy, otherwise `value`. + * @private + * + * @example + * Reflect.apply(live, runtime_liveThis(thisArg), args); + */ +function runtime_liveThis(value) { + const wrapper = _proxyRegistry.get(value); + if (wrapper && runtime_isRestartStale(wrapper)) return runtime_resolveRestartTarget(wrapper); + return value; +} + +/** + * Whether `prop` is a non-configurable own property of a proxy target. Proxy invariants tie the + * answers for such a property to the target itself, so the forwarding traps report it from there. + * + * @param {object} target - Proxy target. + * @param {string|symbol} prop - Property key. + * @returns {boolean} True when the target owns `prop` non-configurably. + * @private + * + * @example + * runtime_isFixedOnTarget(function () {}, "prototype"); // true + */ +function runtime_isFixedOnTarget(target, prop) { + const descriptor = Reflect.getOwnPropertyDescriptor(target, prop); + return descriptor !== undefined && descriptor.configurable === false; +} + +/** + * Proxy traps used once a wrapper's tree has been replaced by restart() (#504): each forwards the + * operation to the node at the same path in the live tree, staying within the proxy invariants of the + * original target (a function target's non-configurable `prototype`, a non-extensible target). + * @type {Record} + * @private + */ +const RESTART_FORWARD_TRAPS = { + get: (live, target, prop) => live[prop], + set: (live, target, prop, value) => Reflect.set(Object(live), prop, value), + has: (live, target, prop) => prop in Object(live) || runtime_isFixedOnTarget(target, prop), + deleteProperty: (live, target, prop) => (runtime_isFixedOnTarget(target, prop) ? false : Reflect.deleteProperty(Object(live), prop)), + ownKeys: (live, target) => { + if (!Reflect.isExtensible(target)) return Reflect.ownKeys(target); + const keys = new Set(Reflect.ownKeys(Object(live))); + for (const key of Reflect.ownKeys(target)) { + if (runtime_isFixedOnTarget(target, key)) keys.add(key); + } + return [...keys]; + }, + getOwnPropertyDescriptor: (live, target, prop) => { + if (runtime_isFixedOnTarget(target, prop) || !Reflect.isExtensible(target)) return Reflect.getOwnPropertyDescriptor(target, prop); + const descriptor = Reflect.getOwnPropertyDescriptor(Object(live), prop); + return descriptor ? { ...descriptor, configurable: true } : undefined; + }, + apply: (live, target, thisArg, args) => Reflect.apply(live, runtime_liveThis(thisArg), args), + construct: (live, target, args, newTarget) => Reflect.construct(live, args, runtime_liveThis(newTarget)), + getPrototypeOf: (live, target) => (Reflect.isExtensible(target) ? Reflect.getPrototypeOf(Object(live)) : Reflect.getPrototypeOf(target)) +}; + +/** + * Wrap a proxy handler so that, once restart() has replaced the wrapper's tree, every trap the + * handler defines forwards to the live node at the wrapper's path instead (#504). Until then the + * original traps run unchanged; the check is one epoch comparison per trap. + * + * @param {object} handler - The proxy handler (as passed to `new Proxy`). + * @param {UnifiedWrapper} wrapper - The wrapper whose epoch decides staleness. + * @param {string[]} [propChain=[]] - Property chain below the wrapper (waiting proxies). + * @returns {object} The wrapped handler. + * @private + * + * @example + * new Proxy(target, runtime_withRestartForwarding({ get, apply }, wrapper)); + */ +function runtime_withRestartForwarding(handler, wrapper, propChain = []) { + const wrapped = {}; + for (const [trap, original] of Object.entries(handler)) { + const forward = RESTART_FORWARD_TRAPS[trap]; + // Fixed arity (no rest/spread): these traps sit on every property access and call, so the + // common, non-stale path must not allocate. No trap takes more than four arguments. + wrapped[trap] = function slothlet_restartForwardingTrap(a, b, c, d) { + if (runtime_isRestartStale(wrapper)) { + return forward(runtime_resolveRestartTarget(wrapper, propChain), a, b, c, d); + } + return original.call(handler, a, b, c, d); + }; + } + return wrapped; +} + /** * Unified wrapper class that handles all proxy concerns in one place: * - __impl pattern for reload support @@ -1166,6 +1308,9 @@ export class UnifiedWrapper extends ComponentBase { // Propagated to getTrap-created descendants so a whole wrap-on-set subtree adopts lazily (#329). internal.deferChildAdopt = deferChildAdopt; internal.invalid = false; + // Restart epoch (#504): the tree this wrapper belongs to. Once a restart() swaps in a newer tree, + // the proxy traps forward to the node at this wrapper's path in the live tree instead. + internal.epoch = slothlet._buildEpoch ?? 0; internal.state = { materialized: initialImpl !== null, inFlight: false, @@ -2799,7 +2944,7 @@ export class UnifiedWrapper extends ComponentBase { // Note: wrapper lookup is via _proxyRegistry (set below) - do NOT attach ____slothletInternal // as an own property on waitingTarget; that was the attack vector we closed in step 2. - const waitingProxy = new Proxy(waitingTarget, { + const waitingHandler = { get(___target, prop) { if (prop === "then") { // Make waiting proxies thenable so `await lg[0]` resolves after materialization @@ -3609,7 +3754,9 @@ export class UnifiedWrapper extends ComponentBase { ); }, getPrototypeOf: () => null - }); + }; + // Once a restart() replaces this wrapper's tree, the waiting proxy forwards to the live tree too (#504). + const waitingProxy = new Proxy(waitingTarget, runtime_withRestartForwarding(waitingHandler, wrapper, propChain)); // Register waiting proxy in the global registry so resolveWrapper() can find the // root wrapper from it - mirrors the old .____slothletInternal.wrapper path that @@ -5431,7 +5578,7 @@ export class UnifiedWrapper extends ComponentBase { ); }; - wrapper.____slothletInternal.proxy = new Proxy(proxyTarget, { + const proxyHandler = { // One chokepoint for capture-binding rather than the many return paths inside getTrap: whatever // the trap resolved, a wrapper handed to a module carries that module's identity from here on. get: (target, prop, receiver) => runtime_bindCapturedIdentity(wrapper, prop, getTrap(target, prop, receiver)), @@ -5473,7 +5620,9 @@ export class UnifiedWrapper extends ComponentBase { if (isLiveIdentity && impl && typeof impl === "object") return Object.getPrototypeOf(impl); return null; } - }); + }; + // Once a restart() replaces this wrapper's tree, every trap forwards to the live tree (#504). + wrapper.____slothletInternal.proxy = new Proxy(proxyTarget, runtime_withRestartForwarding(proxyHandler, wrapper)); _proxyRegistry.set(wrapper.____slothletInternal.proxy, wrapper); // Register this wrapper instance as genuine so permission enforcement can distinguish a real diff --git a/src/lib/i18n/languages/de-de.json b/src/lib/i18n/languages/de-de.json index d9f870492..b738cb1c7 100644 --- a/src/lib/i18n/languages/de-de.json +++ b/src/lib/i18n/languages/de-de.json @@ -505,6 +505,8 @@ "SCOPE_INVALID_PROTECT": "'protect' muss ein Array von String-Schlüsseln sein. Erhalten: {received}.", "SCOPE_INVALID_OWNERS": "'owners' muss ein einfaches Objekt sein, das Schlüssel auf Eigentümernamen abbildet. Erhalten: {received}.", "PERMISSION_SEALED": "Die Berechtigungssteuerung ist versiegelt; die Richtlinie kann nicht mehr geändert werden.", + "RESTART_REFERENCE_UNRESOLVED": "Die Referenz auf '{apiPath}' wurde über einen Neustart hinweg gehalten, und '{apiPath}' existiert in der neu gestarteten API nicht mehr.", + "HINT_RESTART_REFERENCE_UNRESOLVED": "Eine gehaltene Referenz löst ihren Pfad bei jeder Verwendung in der neu gestarteten Instanz neu auf. Lesen Sie den Wert erneut über die API-Wurzel oder prüfen Sie, ob die ursprüngliche Konfiguration '{apiPath}' noch erzeugt — zur Laufzeit per add() eingehängte Module übernimmt restart() nicht.", "PRINCIPAL_NAME_OWNED": "Principal '{name}' gehört '{owner}' und kann nicht von '{claimant}' registriert werden.", "PRINCIPAL_NOT_OWNER": "Die Operation '{operation}' für Principal '{name}' ist dem Besitzer ('{owner}') oder dem Host vorbehalten; Aufrufer: '{caller}'.", "PRINCIPAL_READ_ONLY": "Principal '{name}' ist schreibgeschützt; sein Wert wird nur durch erneutes Auflösen ersetzt.", diff --git a/src/lib/i18n/languages/en-gb.json b/src/lib/i18n/languages/en-gb.json index c2dee7193..d566dd389 100644 --- a/src/lib/i18n/languages/en-gb.json +++ b/src/lib/i18n/languages/en-gb.json @@ -505,6 +505,8 @@ "SCOPE_INVALID_PROTECT": "'protect' must be an array of string keys. Received: {received}.", "SCOPE_INVALID_OWNERS": "'owners' must be a plain object mapping keys to owner names. Received: {received}.", "PERMISSION_SEALED": "The permission control surface is sealed; policy can no longer be modified.", + "RESTART_REFERENCE_UNRESOLVED": "The reference to '{apiPath}' was held across a restart, and '{apiPath}' no longer exists in the restarted API.", + "HINT_RESTART_REFERENCE_UNRESOLVED": "A held reference re-resolves its path in the restarted instance on every use. Re-read the value from the api root, or check that the original config still produces '{apiPath}' — runtime add() mounts are not carried over by restart().", "PRINCIPAL_NAME_OWNED": "Principal '{name}' is owned by '{owner}' and cannot be registered by '{claimant}'.", "PRINCIPAL_NOT_OWNER": "Operation '{operation}' on principal '{name}' is reserved for its owner ('{owner}') or the host; caller: '{caller}'.", "PRINCIPAL_READ_ONLY": "Principal '{name}' is read-only; its value is replaced only when it is resolved again.", diff --git a/src/lib/i18n/languages/en-us.json b/src/lib/i18n/languages/en-us.json index e206d00dd..513ccc4a6 100644 --- a/src/lib/i18n/languages/en-us.json +++ b/src/lib/i18n/languages/en-us.json @@ -505,6 +505,8 @@ "SCOPE_INVALID_PROTECT": "'protect' must be an array of string keys. Received: {received}.", "SCOPE_INVALID_OWNERS": "'owners' must be a plain object mapping keys to owner names. Received: {received}.", "PERMISSION_SEALED": "The permission control surface is sealed; policy can no longer be modified.", + "RESTART_REFERENCE_UNRESOLVED": "The reference to '{apiPath}' was held across a restart, and '{apiPath}' no longer exists in the restarted API.", + "HINT_RESTART_REFERENCE_UNRESOLVED": "A held reference re-resolves its path in the restarted instance on every use. Re-read the value from the api root, or check that the original config still produces '{apiPath}' — runtime add() mounts are not carried over by restart().", "PRINCIPAL_NAME_OWNED": "Principal '{name}' is owned by '{owner}' and cannot be registered by '{claimant}'.", "PRINCIPAL_NOT_OWNER": "Operation '{operation}' on principal '{name}' is reserved for its owner ('{owner}') or the host; caller: '{caller}'.", "PRINCIPAL_READ_ONLY": "Principal '{name}' is read-only; its value is replaced only when it is resolved again.", diff --git a/src/lib/i18n/languages/es-es.json b/src/lib/i18n/languages/es-es.json index cc2d3a722..c856adb11 100644 --- a/src/lib/i18n/languages/es-es.json +++ b/src/lib/i18n/languages/es-es.json @@ -505,6 +505,8 @@ "SCOPE_INVALID_PROTECT": "'protect' debe ser un array de claves de tipo cadena. Recibido: {received}.", "SCOPE_INVALID_OWNERS": "'owners' debe ser un objeto simple que asigne claves a nombres de propietario. Recibido: {received}.", "PERMISSION_SEALED": "La superficie de control de permisos está sellada; la política ya no se puede modificar.", + "RESTART_REFERENCE_UNRESOLVED": "La referencia a '{apiPath}' se mantuvo durante un reinicio y '{apiPath}' ya no existe en la API reiniciada.", + "HINT_RESTART_REFERENCE_UNRESOLVED": "Una referencia retenida vuelve a resolver su ruta en la instancia reiniciada en cada uso. Vuelve a leer el valor desde la raíz de la API o comprueba que la configuración original todavía produce '{apiPath}': restart() no conserva los montajes hechos con add() en tiempo de ejecución.", "PRINCIPAL_NAME_OWNED": "El principal '{name}' pertenece a '{owner}' y '{claimant}' no puede registrarlo.", "PRINCIPAL_NOT_OWNER": "La operación '{operation}' sobre el principal '{name}' está reservada a su propietario ('{owner}') o al host; llamador: '{caller}'.", "PRINCIPAL_READ_ONLY": "El principal '{name}' es de solo lectura; su valor solo se reemplaza al resolverlo de nuevo.", diff --git a/src/lib/i18n/languages/es-mx.json b/src/lib/i18n/languages/es-mx.json index cdc5d4a6f..36d678dfe 100644 --- a/src/lib/i18n/languages/es-mx.json +++ b/src/lib/i18n/languages/es-mx.json @@ -505,6 +505,8 @@ "SCOPE_INVALID_PROTECT": "'protect' tiene que ser un arreglo de claves de tipo cadena. Recibido: {received}.", "SCOPE_INVALID_OWNERS": "'owners' tiene que ser un objeto simple que asigne claves a nombres de propietario. Recibido: {received}.", "PERMISSION_SEALED": "La superficie de control de permisos está sellada; ya no es posible modificar la política.", + "RESTART_REFERENCE_UNRESOLVED": "La referencia a '{apiPath}' se conservó durante un reinicio y '{apiPath}' ya no existe en la API reiniciada.", + "HINT_RESTART_REFERENCE_UNRESOLVED": "Una referencia conservada vuelve a resolver su ruta en la instancia reiniciada cada vez que se usa. Vuelve a leer el valor desde la raíz de la API o verifica que la configuración original todavía genere '{apiPath}': restart() no conserva los montajes hechos con add() en tiempo de ejecución.", "PRINCIPAL_NAME_OWNED": "El principal '{name}' pertenece a '{owner}' y '{claimant}' no puede registrarlo.", "PRINCIPAL_NOT_OWNER": "La operación '{operation}' sobre el principal '{name}' está reservada a su propietario ('{owner}') o al host; llamador: '{caller}'.", "PRINCIPAL_READ_ONLY": "El principal '{name}' es de solo lectura; su valor solo se reemplaza al resolverlo de nuevo.", diff --git a/src/lib/i18n/languages/fr-fr.json b/src/lib/i18n/languages/fr-fr.json index 772de4b26..869e5e4bd 100644 --- a/src/lib/i18n/languages/fr-fr.json +++ b/src/lib/i18n/languages/fr-fr.json @@ -505,6 +505,8 @@ "SCOPE_INVALID_PROTECT": "'protect' doit être un tableau de clés de type chaîne. Reçu : {received}.", "SCOPE_INVALID_OWNERS": "'owners' doit être un objet simple associant des clés à des noms de propriétaire. Reçu : {received}.", "PERMISSION_SEALED": "La surface de contrôle des permissions est scellée ; la politique ne peut plus être modifiée.", + "RESTART_REFERENCE_UNRESOLVED": "La référence à '{apiPath}' a été conservée pendant un redémarrage, et '{apiPath}' n'existe plus dans l'API redémarrée.", + "HINT_RESTART_REFERENCE_UNRESOLVED": "Une référence conservée résout à nouveau son chemin dans l'instance redémarrée à chaque utilisation. Relisez la valeur depuis la racine de l'API, ou vérifiez que la configuration d'origine produit toujours '{apiPath}' : restart() ne conserve pas les montages add() effectués à l'exécution.", "PRINCIPAL_NAME_OWNED": "Le principal '{name}' appartient à '{owner}' et ne peut pas être enregistré par '{claimant}'.", "PRINCIPAL_NOT_OWNER": "L'opération '{operation}' sur le principal '{name}' est réservée à son propriétaire ('{owner}') ou à l'hôte ; appelant : '{caller}'.", "PRINCIPAL_READ_ONLY": "Le principal '{name}' est en lecture seule ; sa valeur n'est remplacée que lorsqu'il est résolu à nouveau.", diff --git a/src/lib/i18n/languages/hi-in.json b/src/lib/i18n/languages/hi-in.json index 5b74ceea0..055b7e2b3 100644 --- a/src/lib/i18n/languages/hi-in.json +++ b/src/lib/i18n/languages/hi-in.json @@ -505,6 +505,8 @@ "SCOPE_INVALID_PROTECT": "'protect' स्ट्रिंग कुंजियों की एक सरणी होनी चाहिए। प्राप्त: {received}।", "SCOPE_INVALID_OWNERS": "'owners' एक सादा ऑब्जेक्ट होना चाहिए जो कुंजियों को स्वामी नामों से मैप करता है। प्राप्त: {received}।", "PERMISSION_SEALED": "अनुमति नियंत्रण सतह सील है; नीति अब संशोधित नहीं की जा सकती।", + "RESTART_REFERENCE_UNRESOLVED": "'{apiPath}' का संदर्भ पुनः आरंभ के दौरान रखा गया था, और पुनः आरंभ किए गए API में '{apiPath}' अब मौजूद नहीं है।", + "HINT_RESTART_REFERENCE_UNRESOLVED": "रखा गया संदर्भ हर उपयोग पर पुनः आरंभ किए गए इंस्टेंस में अपना पथ फिर से हल करता है। API रूट से मान फिर से पढ़ें, या जाँचें कि मूल कॉन्फ़िगरेशन अब भी '{apiPath}' बनाता है — restart() रनटाइम पर add() से जोड़े गए माउंट नहीं रखता।", "PRINCIPAL_NAME_OWNED": "प्रिंसिपल '{name}' का स्वामी '{owner}' है; '{claimant}' इसे पंजीकृत नहीं कर सकता।", "PRINCIPAL_NOT_OWNER": "प्रिंसिपल '{name}' पर संचालन '{operation}' केवल उसके स्वामी ('{owner}') या होस्ट के लिए आरक्षित है; कॉलर: '{caller}'।", "PRINCIPAL_READ_ONLY": "प्रिंसिपल '{name}' केवल-पठन है; इसका मान केवल दोबारा हल करने पर ही बदला जाता है।", diff --git a/src/lib/i18n/languages/ja-jp.json b/src/lib/i18n/languages/ja-jp.json index b992cd26e..b7658d961 100644 --- a/src/lib/i18n/languages/ja-jp.json +++ b/src/lib/i18n/languages/ja-jp.json @@ -505,6 +505,8 @@ "SCOPE_INVALID_PROTECT": "'protect' は文字列キーの配列である必要があります。受け取った値: {received}。", "SCOPE_INVALID_OWNERS": "'owners' はキーを所有者名にマッピングするプレーンオブジェクトである必要があります。受け取った値: {received}。", "PERMISSION_SEALED": "権限制御サーフェスは封印されており、ポリシーを変更できません。", + "RESTART_REFERENCE_UNRESOLVED": "'{apiPath}' への参照が再起動をまたいで保持されましたが、再起動後の API に '{apiPath}' は存在しません。", + "HINT_RESTART_REFERENCE_UNRESOLVED": "保持された参照は、使用するたびに再起動後のインスタンスでパスを解決し直します。API のルートから値を読み直すか、元の設定がまだ '{apiPath}' を生成するか確認してください。実行時の add() によるマウントは restart() で引き継がれません。", "PRINCIPAL_NAME_OWNED": "プリンシパル '{name}' の所有者は '{owner}' であり、'{claimant}' は登録できません。", "PRINCIPAL_NOT_OWNER": "プリンシパル '{name}' に対する操作 '{operation}' は所有者('{owner}')またはホストのみが実行できます。呼び出し元: '{caller}'。", "PRINCIPAL_READ_ONLY": "プリンシパル '{name}' は読み取り専用です。値は再解決されたときにのみ置き換えられます。", diff --git a/src/lib/i18n/languages/ko-kr.json b/src/lib/i18n/languages/ko-kr.json index f4f84d8a1..dd27176aa 100644 --- a/src/lib/i18n/languages/ko-kr.json +++ b/src/lib/i18n/languages/ko-kr.json @@ -505,6 +505,8 @@ "SCOPE_INVALID_PROTECT": "'protect'는 문자열 키의 배열이어야 합니다. 받은 값: {received}.", "SCOPE_INVALID_OWNERS": "'owners'는 키를 소유자 이름에 매핑하는 일반 객체여야 합니다. 받은 값: {received}.", "PERMISSION_SEALED": "권한 제어 표면이 봉인되어 있어 정책을 더 이상 수정할 수 없습니다.", + "RESTART_REFERENCE_UNRESOLVED": "'{apiPath}'에 대한 참조가 재시작 동안 유지되었지만, 재시작된 API에 '{apiPath}'가 더 이상 존재하지 않습니다.", + "HINT_RESTART_REFERENCE_UNRESOLVED": "유지된 참조는 사용할 때마다 재시작된 인스턴스에서 경로를 다시 확인합니다. API 루트에서 값을 다시 읽거나, 원래 설정이 여전히 '{apiPath}'를 생성하는지 확인하세요. 런타임 add() 마운트는 restart() 후에 유지되지 않습니다.", "PRINCIPAL_NAME_OWNED": "프린시펄 '{name}'의 소유자는 '{owner}'이며 '{claimant}'은(는) 등록할 수 없습니다.", "PRINCIPAL_NOT_OWNER": "프린시펄 '{name}'에 대한 작업 '{operation}'은(는) 소유자('{owner}') 또는 호스트만 수행할 수 있습니다. 호출자: '{caller}'.", "PRINCIPAL_READ_ONLY": "프린시펄 '{name}'은(는) 읽기 전용입니다. 값은 다시 해석될 때만 교체됩니다.", diff --git a/src/lib/i18n/languages/pt-br.json b/src/lib/i18n/languages/pt-br.json index 8ee7f555b..de30a818c 100644 --- a/src/lib/i18n/languages/pt-br.json +++ b/src/lib/i18n/languages/pt-br.json @@ -505,6 +505,8 @@ "SCOPE_INVALID_PROTECT": "'protect' deve ser um array de chaves do tipo string. Recebido: {received}.", "SCOPE_INVALID_OWNERS": "'owners' deve ser um objeto simples que mapeia chaves para nomes de proprietário. Recebido: {received}.", "PERMISSION_SEALED": "A superfície de controle de permissões está selada; a política não pode mais ser modificada.", + "RESTART_REFERENCE_UNRESOLVED": "A referência a '{apiPath}' foi mantida durante uma reinicialização, e '{apiPath}' não existe mais na API reiniciada.", + "HINT_RESTART_REFERENCE_UNRESOLVED": "Uma referência mantida resolve seu caminho novamente na instância reiniciada a cada uso. Leia o valor de novo a partir da raiz da API ou verifique se a configuração original ainda produz '{apiPath}' — restart() não mantém montagens add() feitas em tempo de execução.", "PRINCIPAL_NAME_OWNED": "O principal '{name}' pertence a '{owner}' e não pode ser registrado por '{claimant}'.", "PRINCIPAL_NOT_OWNER": "A operação '{operation}' no principal '{name}' é reservada ao seu proprietário ('{owner}') ou ao host; chamador: '{caller}'.", "PRINCIPAL_READ_ONLY": "O principal '{name}' é somente leitura; seu valor só é substituído quando ele é resolvido novamente.", diff --git a/src/lib/i18n/languages/ru-ru.json b/src/lib/i18n/languages/ru-ru.json index 0c0edb836..eff1f5133 100644 --- a/src/lib/i18n/languages/ru-ru.json +++ b/src/lib/i18n/languages/ru-ru.json @@ -505,6 +505,8 @@ "SCOPE_INVALID_PROTECT": "'protect' должен быть массивом строковых ключей. Получено: {received}.", "SCOPE_INVALID_OWNERS": "'owners' должен быть простым объектом, сопоставляющим ключи с именами владельцев. Получено: {received}.", "PERMISSION_SEALED": "Поверхность управления разрешениями запечатана; политику больше нельзя изменить.", + "RESTART_REFERENCE_UNRESOLVED": "Ссылка на '{apiPath}' была сохранена во время перезапуска, и '{apiPath}' больше не существует в перезапущенном API.", + "HINT_RESTART_REFERENCE_UNRESOLVED": "Сохранённая ссылка заново разрешает свой путь в перезапущенном экземпляре при каждом использовании. Прочитайте значение заново от корня API или проверьте, что исходная конфигурация всё ещё создаёт '{apiPath}': restart() не переносит модули, подключённые через add() во время выполнения.", "PRINCIPAL_NAME_OWNED": "Принципал '{name}' принадлежит '{owner}' и не может быть зарегистрирован '{claimant}'.", "PRINCIPAL_NOT_OWNER": "Операция '{operation}' над принципалом '{name}' доступна только его владельцу ('{owner}') или хосту; вызывающий: '{caller}'.", "PRINCIPAL_READ_ONLY": "Принципал '{name}' доступен только для чтения; его значение заменяется только при повторном разрешении.", diff --git a/src/lib/i18n/languages/zh-cn.json b/src/lib/i18n/languages/zh-cn.json index 2ffa8eeac..b93ca53c8 100644 --- a/src/lib/i18n/languages/zh-cn.json +++ b/src/lib/i18n/languages/zh-cn.json @@ -505,6 +505,8 @@ "SCOPE_INVALID_PROTECT": "'protect' 必须是字符串键的数组。收到: {received}。", "SCOPE_INVALID_OWNERS": "'owners' 必须是将键映射到所有者名称的普通对象。收到: {received}。", "PERMISSION_SEALED": "权限控制面已封印;无法再修改策略。", + "RESTART_REFERENCE_UNRESOLVED": "对 '{apiPath}' 的引用在重启期间被保留,但重启后的 API 中已不存在 '{apiPath}'。", + "HINT_RESTART_REFERENCE_UNRESOLVED": "被保留的引用每次使用时都会在重启后的实例中重新解析其路径。请从 API 根重新读取该值,或确认原始配置仍会生成 '{apiPath}'——restart() 不会保留运行时通过 add() 挂载的内容。", "PRINCIPAL_NAME_OWNED": "主体 '{name}' 归 '{owner}' 所有,'{claimant}' 无法注册它。", "PRINCIPAL_NOT_OWNER": "对主体 '{name}' 的操作 '{operation}' 仅限其所有者('{owner}')或宿主执行;调用方:'{caller}'。", "PRINCIPAL_READ_ONLY": "主体 '{name}' 为只读;其值仅在重新解析时被替换。", diff --git a/src/slothlet.mjs b/src/slothlet.mjs index 6562b0728..9ca4293d2 100644 --- a/src/slothlet.mjs +++ b/src/slothlet.mjs @@ -124,6 +124,77 @@ import { DEFAULT_ROUTINES, RESERVED_EXPORTS } from "@cldmv/slothlet/helpers/defa */ const boundaryPatchHolders = new Set(); +/** + * Top-level config keys retained by identity when the original config is snapshotted for + * `restart()`: `reference` and `context` are user-owned live objects the instance exposes as-is, so + * a restart hands the new instance the same objects the original `slothlet({...})` call received. + * + * @type {ReadonlySet} + * @private + */ +const CONFIG_IDENTITY_KEYS = new Set(["reference", "context"]); + +/** + * Copy a config value for the retained original-config snapshot (#504). + * + * @description + * Plain objects and arrays are copied recursively so a later mutation of the caller's own options + * object (or of anything config normalization touches) cannot change what `restart()` rebuilds from. + * Everything else — functions, class instances, `Map`/`Set`, `Date`, `RegExp`, `URL` — is kept by + * identity, as the original call passed it. With `freeze`, every copied container is frozen so the + * retained snapshot itself is immutable; `restart()` takes an unfrozen copy of it for each rebuild, + * because normalization receives a config it is free to read into new objects. + * + * @param {unknown} value - Value to copy. + * @param {boolean} freeze - Freeze each copied container. + * @param {Map} [seen=new Map()] - Cycle guard: source container → its copy. + * @returns {unknown} The copy (or the value itself for anything that is not a plain container). + * @private + * + * @example + * const snapshot = copyConfigValue({ base: "./api", permissions: { rules: [] } }, true); + */ +function copyConfigValue(value, freeze, seen = new Map()) { + if (!value || typeof value !== "object") return value; + if (seen.has(value)) return seen.get(value); + if (Array.isArray(value)) { + const out = []; + seen.set(value, out); + for (const item of value) out.push(copyConfigValue(item, freeze, seen)); + return freeze ? Object.freeze(out) : out; + } + const proto = Object.getPrototypeOf(value); + if (proto !== Object.prototype && proto !== null) return value; + const out = proto === null ? Object.create(null) : {}; + seen.set(value, out); + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor.enumerable) continue; + out[key] = copyConfigValue(value[key], freeze, seen); + } + return freeze ? Object.freeze(out) : out; +} + +/** + * Copy a top-level config object for the retained snapshot, keeping {@link CONFIG_IDENTITY_KEYS} by + * identity. + * + * @param {object} config - Top-level config. + * @param {boolean} freeze - Freeze the copied containers. + * @returns {object} The copy. + * @private + * + * @example + * const snapshot = copyTopLevelConfig({ base: "./api", context: ctx }, true); // snapshot.context === ctx + */ +function copyTopLevelConfig(config, freeze) { + const out = {}; + for (const key of Object.keys(config)) { + out[key] = CONFIG_IDENTITY_KEYS.has(key) ? config[key] : copyConfigValue(config[key], freeze); + } + return freeze ? Object.freeze(out) : out; +} + /** * Slothlet instance - clean architecture prototype * @private @@ -157,6 +228,21 @@ class Slothlet { // Environment snapshot (captured before any module lifecycle in load()) this.envSnapshot = null; + // Clean-slate restart (#504). `_originalConfig` is the frozen snapshot of the config the + // `slothlet({...})` call received; `_originalBase` is its base as resolved against that call's + // caller (a relative base must not be re-resolved against whoever calls restart()). + this._originalConfig = null; + this._originalBase = undefined; + // Restart epochs: every UnifiedWrapper records `_buildEpoch` at construction; a wrapper whose + // epoch is below `_liveEpoch` belongs to a tree a restart replaced, and its proxy forwards to the + // node at the same path in the live tree (see unified-wrapper.mjs). + this._buildEpoch = 0; + this._liveEpoch = 0; + // In-flight restart() promise — concurrent calls share it. + this._restartPromise = null; + // True for the duration of restart() (tags the `shutdown` / `init` lifecycle events). + this._restarting = false; + // Lazy materialization tracking (for api.slothlet.materialize) this._totalLazyCount = 0; // Total lazy wrappers created this._unmaterializedLazyCount = 0; // Currently unmaterialized lazy wrappers @@ -740,6 +826,12 @@ class Slothlet { // (#529) — decided here, per load, once the effective list is known. this.handlers.permissionManager?.applyDefaultRoutineRules(this.config.routines); + // Pin the original base as resolved against the creating call's caller, so restart() rebuilds + // from the same directory rather than re-resolving a relative base from its own call site (#504). + if (this._originalConfig && this._originalBase === undefined) { + this._originalBase = this.config.dir; + } + // One-shot DX hint (#235): under a vitest COVERAGE run with this slothlet copy externalized // and no injectable importer configured, the consumer's leaf coverage will misattribute — // say so at boot, pointing at the fix, instead of leaving a mysteriously low report. @@ -980,6 +1072,13 @@ class Slothlet { this.isLoaded = true; + // `init` (#504): a new instance finished building — the cold start, and the rebuild inside + // restart(). A reload (which always passes an instance ID to preserve or rotate) is the same + // instance re-reading its modules, not a new one, so it does not emit this. + if (!preservedInstanceID) { + await this.handlers.lifecycle.emit("init", { instanceID: this.instanceID, restart: this._restarting }); + } + return this.boundApi; } @@ -1204,6 +1303,163 @@ class Slothlet { return this.boundApi; } + /** + * Retain an immutable snapshot of the config a `slothlet({...})` call received, for restart() (#504). + * + * @param {object} [config={}] - The config exactly as the creating call passed it. + * @returns {void} + * @private + * + * @example + * instance._retainOriginalConfig({ base: "./api", mode: "lazy" }); + */ + _retainOriginalConfig(config = {}) { + this._originalConfig = copyTopLevelConfig(config ?? {}, true); + } + + /** + * Clean-slate restart: tear this instance down and build a new one from its ORIGINAL config, + * behind the same `api` reference (#504). + * @public + * + * @description + * Unlike {@link Slothlet#reload}, nothing runtime survives: no replayed `add()` / `remove()` + * history, no runtime hooks, permission rules, event subscriptions, principals, metadata or + * lifecycle subscribers, no `self.x = …` assignments, and no module-scope state (every module is + * re-imported under a new instance ID). Everything declared in the original config is applied + * again, because the new instance is built from it. That includes the permission policy: the new + * instance has exactly the original config's rules and is NOT sealed, even if the old one was — + * exactly as a fresh `slothlet({...})` call would be. When a `permissions` config is present, + * modules cannot call it (a built-in rule denies `slothlet.restart`; the host can allow a trusted + * module explicitly); with no `permissions` config the permission system is off and it is reachable. + * + * Sequence: `restart` lifecycle event (old instance, nothing torn down yet) → the normal teardown + * (`mode: "shutdown"` routines, the root `shutdown` hook, then the internal teardown, which emits + * `shutdown`) → a fresh load from the original config (emits `init` on the new instance) → held + * child references from the old tree are re-pointed at the new tree → `restarted`. + * + * Events on the old instance reach the old instance's subscribers (config-declared and runtime); + * `init` and `restarted` fire on the new instance, where only config-declared (`lifecycle` option) + * subscribers exist — runtime subscribers were dropped with the old instance. + * + * Calls already running when restart() starts finish on the old implementation; restart() does not + * wait for them (as with reload(), only in-flight lazy materializations are drained). Concurrent + * restart() calls share one restart. A failing teardown step (a throwing shutdown routine or hook) + * does not stop the rebuild; its error is re-thrown once the new instance is in place. + * + * @returns {Promise} The same bound API reference. + * @throws {SlothletError} INVALID_CONFIG_NOT_LOADED when the instance was never created via `slothlet()`. + * + * @example + * await api.slothlet.restart(); + */ + restart() { + if (this._restartPromise) return this._restartPromise; + this._restartPromise = this._restart().finally(() => { + this._restartPromise = null; + }); + return this._restartPromise; + } + + /** + * restart() body — see {@link Slothlet#restart}. + * @returns {Promise} The same bound API reference. + * @private + * + * @example + * await this._restart(); + */ + async _restart() { + if (!this._originalConfig) { + throw new SlothletError("INVALID_CONFIG_NOT_LOADED", { + operation: "restart", + validationError: true + }); + } + + const previousInstanceID = this.instanceID; + let teardownError = null; + this._restarting = true; + try { + // 1. `restart` — before anything is torn down. + await this.handlers.lifecycle.emit("restart", { instanceID: previousInstanceID }); + + // 2. Normal teardown of the old instance — the same steps as the root `api.shutdown()` dispose + // path: `mode: "shutdown"` routines, the root shutdown hook (whatever the user's routines + // config makes of it), then the internal teardown that emits `shutdown`. Run directly rather + // than through the root builtin, and as the host: this is framework-internal teardown, so a + // module that was allowed to call restart() must not be refused halfway by a routine's or + // hook's own call gate. A throwing routine/hook is captured and re-thrown at the end. + if (this.isLoaded) { + teardownError = await this._runAsHost(async () => { + let firstError = null; + try { + await this.handlers.routineManager?.runShutdownModeRoutines(); + } catch (error) { + firstError = error; + } + try { + if (typeof this.userHooks?.shutdown === "function") { + await this.userHooks.shutdown(); + } + } catch (error) { + firstError ??= error; + } + await this.shutdown(); + return firstError; + }); + } + + // 3. Reset the per-instance state the Slothlet shell carries between loads, so the new + // instance starts exactly as a fresh `slothlet({...})` would. + await this._clearModuleCaches(); + this.envSnapshot = null; + this._typesGenerated = false; + this._totalLazyCount = 0; + this._unmaterializedLazyCount = 0; + this._materializationComplete = false; + this._materializationCompleteEmitted = false; + + // 4. Build the new instance from the original config. New wrappers carry the next epoch; + // the old tree's wrappers keep answering as themselves until the swap below. + const config = copyTopLevelConfig(this._originalConfig, false); + if (this._originalBase !== undefined) { + config.base = this._originalBase; + } + this._buildEpoch = this._liveEpoch + 1; + await this.load(config); + + // 5. Swap complete: every wrapper from an earlier epoch now forwards to the node at its path + // in the new tree (or throws RESTART_REFERENCE_UNRESOLVED if the path is gone). + this._liveEpoch = this._buildEpoch; + } finally { + this._restarting = false; + } + + // 6. `restarted` — on the new instance. + await this.handlers.lifecycle.emit("restarted", { instanceID: this.instanceID, previousInstanceID }); + + if (teardownError) throw teardownError; + return this.boundApi; + } + + /** + * Run framework-internal work as the host: with no module caller, even when a module's call is what + * triggered it (e.g. a module allowed to call `restart()`). Host-initiated work runs as is. + * + * @param {Function} fn - The work. + * @returns {Promise} Whatever `fn` resolves to. + * @private + * + * @example + * await this._runAsHost(() => this.shutdown()); + */ + async _runAsHost(fn) { + const identity = this.contextManager?.getCallerIdentity?.(this.instanceID); + if (!identity?.currentWrapper && !identity?.unresolved) return fn(); + return this.contextManager.runInContext(this.instanceID, fn, null, [], null, true, true); + } + /** * Clear Node.js module caches for reloading * @private @@ -1346,6 +1602,11 @@ class Slothlet { return; } + // `shutdown` (#504): teardown is starting — emitted on the instance's own emitter while every + // system is still up, so its subscribers (config-declared and runtime alike) can still reach the + // api. `restart` is true when the teardown is the first half of a restart(). + await this.handlers.lifecycle.emit("shutdown", { instanceID: this.instanceID, restart: this._restarting }); + // Drain any in-flight lazy-mode module loads before tearing down, so that // background import() calls can complete and won't cause "Closing rpc while // fetch was pending" errors in Vitest workers. @@ -1528,6 +1789,8 @@ class Slothlet { */ export async function slothlet(config) { const instance = new Slothlet(); + // Retain the config as passed, before anything reads it, so restart() can rebuild from it (#504). + instance._retainOriginalConfig(config); const api = await instance.load(config); // API already has builtins attached from load() @@ -1619,7 +1882,7 @@ export default slothlet; * Pass an object with sub-keys `builder`, `api`, `index`, `modes`, `wrapper`, `ownership`, `context` to target specific subsystems. * @property {boolean} [silent=false] - Suppress all console output from slothlet (warnings, deprecations). Does not affect `debug`. * @property {boolean} [diagnostics=false] - Enable the `api.slothlet.diag.*` introspection namespace. Intended for testing; do not enable in production. - * @property {Object.} [lifecycle] - Construction-time lifecycle subscribers, registered on the lifecycle emitter BEFORE the api builds so events emitted during cold-start `buildAPI` (init-time `impl:warning` / `impl:created` / …) are observable. Maps an event name to a handler `function(data, token)` or an array of them; any event name is accepted. Because they are ordinary subscribers, they also receive runtime events afterward — equivalent to calling `api.slothlet.lifecycle.on(event, fn)` for each, but early enough to catch initialization diagnostics. Example: `{ "impl:warning": (d) => log(d), "impl:error": [onError, audit] }`. + * @property {Object.} [lifecycle] - Construction-time lifecycle subscribers, registered on the lifecycle emitter BEFORE the api builds so events emitted during cold-start `buildAPI` (init-time `impl:warning` / `impl:created` / …) are observable. Maps an event name to a handler `function(data, token)` or an array of them; any event name is accepted. Because they are ordinary subscribers, they also receive runtime events afterward — equivalent to calling `api.slothlet.lifecycle.on(event, fn)` for each, but early enough to catch initialization diagnostics. Because they come from the config, `api.slothlet.restart()` subscribes them again on the new instance, so they also receive the new instance's `init` and `restarted` events (runtime `lifecycle.on` subscribers are dropped with the old instance). Example: `{ "impl:warning": (d) => log(d), "impl:error": [onError, audit] }`. * @property {boolean} [collectLifecycleHooks=false] - DEPRECATED — will be removed in v4. Expands into two implicit `routines` entries (`{name: "^**.shutdown", mode: "shutdown", order: "depth"}` and the `destroy` equivalent) reproducing this option's original whole-tree, cross-mount, deepest-first scope for literally-named `shutdown`/`destroy` leaves, dropping any existing `shutdown`/`destroy`-mode routine (including the built-in `shutdown` default) in favor of these — and sets the effective `autoRoutines` to `true` unless `autoRoutines` is given explicitly. Nested hooks remain directly callable regardless. Emits a `V3_CONFIG_DEPRECATED` warning unless `silent: true`. * @property {Array} [routines] - Stackable lifecycle routines (#341). Every mounted module exporting a function matching a configured routine name is composed into one callable at its exact composed api path, plus a root cascade (`self.()`, i.e. `api.()`) that runs every matching contribution anywhere. Entries: `"name"` (mode `"manual"`), `"name:mode"`, or `{ name, mode?, recursive?, order?, cascade? }` (`recursive`/`order`/`cascade` only settable via the object form). `name` is mount-relative by default (a bare name matches only a mount's own top level; a dotted name matches a fixed relative sub-path, or with `recursive: true` any depth within the mount); a `^`-prefixed name is root-anchored, matched via glob (`*`, `**`, `{}`, `!`) against the full api path, crossing mount boundaries. `order` (`"mount"` | `"depth"`, mode-defaulted) controls the root cascade's grouping order. `cascade` (default `true`) controls whether the root `api.()` run-all cascade is created at all — set `cascade: false` (#400) for a per-entity lifecycle routine, where the host must invoke exactly one co-owner by key via `api...for(moduleID)(...)` rather than a run-all; every stacked path also exposes `api...contributors` (the moduleIDs present there). `.for(key)` runs that one contributor in its own extent/identity with the args passed straight through, and bypasses the `stackRoutines` owner-filter so a specific co-owner runs even if it lost the shared-path collision. Providing `routines` at all REPLACES the built-in defaults (`slothlet.defaults.routines`: `initialize` → `startup`, `shutdown` → `shutdown`) — spread `slothlet.defaults.routines` to extend them instead, or pass `[]` to disable every routine. Every configured routine is always wrapped and directly callable regardless of `autoRoutines`. Whether two or more contributors colliding at the identical api path all run is governed by `stackRoutines` (#365), independent of `collisionMode` — by default only the single contribution that actually owns that path runs, matching ordinary collision behavior. A throwing contributor doesn't stop the chain — every contributor runs (best-effort), and one aggregate `ROUTINE_FAILED` error is thrown afterward if any failed. See [LIFECYCLE.md](docs/LIFECYCLE.md#routines). * @property {boolean} [autoRoutines=false] - The non-deprecated replacement for `collectLifecycleHooks`. TEMPORARY v3-compat default (#341): `false` for now, so a project upgrading sees no behavior change from a pre-existing nested leaf that happens to share a routine's name (e.g. `shutdown`) — it stays stacked and directly callable, but does not start auto-firing. When `true`, every `mode: "startup"` routine's cascade runs at the end of compose, and every `mode: "shutdown"`/`"destroy"` routine's cascade runs on the corresponding dispose call. Planned to default to `true` in v4 (`collectLifecycleHooks` removed at the same time) — see [LIFECYCLE.md](docs/LIFECYCLE.md#routines). @@ -1773,6 +2036,7 @@ export default slothlet; * @property {Function} slothlet.bind - Freeze the entire async context onto a callback at registration time (convenience re-export of `AsyncResource.bind`). Unlike `lockCaller`, which overrides only caller identity and leaves request-scoped context live, `bind` freezes every `AsyncLocalStorage`, including slothlet's caller and context. Meaningful in async runtime mode; degrades to non-slothlet ALS context in live mode. %%sig: (fn: function): function%% %%example: // Inside a module's init()|server.addHook("onRequest", self.slothlet.bind(handler));%% * @property {object} [slothlet.reference] - The `reference` object from config, merged onto the root API and accessible here. * @property {Function} slothlet.reload - Reload the entire instance (re-scans the directory and recreates all module references). Accepts `{ keepInstanceID: boolean }`. %%sig: ([options]: Object): Promise.%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api' });|await api.slothlet.reload(); // full reload%% %%example: // ESM usage via slothlet API (inside async function)|async function example() {| const { default: slothlet } = await import("@cldmv/slothlet");| const api = await slothlet({ base: './api' });| await api.slothlet.reload(); // full reload|}%% %%example: // CJS usage via slothlet API (top-level)|let slothlet;|(async () => {| ({ slothlet } = await import("@cldmv/slothlet"));| const api = await slothlet({ base: './api' });| await api.slothlet.reload(); // full reload|})();%% %%example: // CJS usage via slothlet API (inside async function)|const slothlet = require("@cldmv/slothlet");|const api = await slothlet({ base: './api' });|await api.slothlet.reload(); // full reload%% + * @property {Function} slothlet.restart - Clean-slate restart: shut this instance down through the normal teardown path and build a new one from the ORIGINAL `slothlet({...})` config behind the same `api` reference. Unlike `reload()`, nothing runtime is carried over — no replayed `add()`/`remove()` history, runtime hooks, permission rules, event or lifecycle subscriptions, metadata, runtime assignments or module-scope state (modules are re-imported under a new instance ID). Held child references are re-pointed to the node at the same path in the new instance; one whose path no longer exists throws `RESTART_REFERENCE_UNRESOLVED` on use. Emits `restart` → `shutdown` → `init` → `restarted`. Refused with `INVALID_CONFIG_MUTATIONS_DISABLED` when `api.mutations.reload` is `false`, and, when a `permissions` config is present, host-only (a built-in rule denies `slothlet.restart` to modules; an explicit host rule can allow a trusted module — with no `permissions` config the permission system is off and modules can call it). The new instance comes up with only the original config's permission rules and unsealed, even if the old one was sealed. See [RELOAD.md](docs/RELOAD.md#apislothletrestart). %%sig: (): Promise.%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api' });|api.conn.handlers = { onSend };|await api.slothlet.restart(); // clean slate from the original config|api.conn.handlers; // undefined%% %%example: // ESM usage via slothlet API (inside async function)|async function example() {| const { default: slothlet } = await import("@cldmv/slothlet");| const api = await slothlet({ base: './api' });| await api.slothlet.restart();|}%% %%example: // CJS usage via slothlet API (top-level)|let slothlet;|(async () => {| ({ slothlet } = await import("@cldmv/slothlet"));| const api = await slothlet({ base: './api' });| await api.slothlet.restart();|})();%% %%example: // CJS usage via slothlet API (inside async function)|const slothlet = require("@cldmv/slothlet");|const api = await slothlet({ base: './api' });|await api.slothlet.restart();%% * @property {Function} slothlet.run - Execute a callback with isolated per-request context data. Convenience alias for `slothlet.context.run()`. %%sig: (contextData: Object, callback: function, args: *): *%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api' });|const result = await api.slothlet.run({ userId: 42 }, async () => {| return api.myModule.getUser();|});%% %%example: // ESM usage via slothlet API (inside async function)|async function example() {| const { default: slothlet } = await import("@cldmv/slothlet");| const api = await slothlet({ base: './api' });| const result = await api.slothlet.run({ userId: 42 }, async () => {| return api.myModule.getUser();| });|}%% %%example: // CJS usage via slothlet API (top-level)|let slothlet;|(async () => {| ({ slothlet } = await import("@cldmv/slothlet"));| const api = await slothlet({ base: './api' });| const result = await api.slothlet.run({ userId: 42 }, async () => {| return api.myModule.getUser();| });|})();%% %%example: // CJS usage via slothlet API (inside async function)|const slothlet = require("@cldmv/slothlet");|const api = await slothlet({ base: './api' });|const result = await api.slothlet.run({ userId: 42 }, async () => {| return api.myModule.getUser();|});%% * @property {Function} slothlet.scope - Execute a function with full structured per-request context options. Convenience alias for `slothlet.context.scope()`. %%sig: (options: Object): *%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api' });|const result = await api.slothlet.scope({| context: { userId: 42 },| fn: async () => api.myModule.getUser()|});%% %%example: // ESM usage via slothlet API (inside async function)|async function example() {| const { default: slothlet } = await import("@cldmv/slothlet");| const api = await slothlet({ base: './api' });| const result = await api.slothlet.scope({| context: { userId: 42 },| fn: async () => api.myModule.getUser()| });|}%% %%example: // CJS usage via slothlet API (top-level)|let slothlet;|(async () => {| ({ slothlet } = await import("@cldmv/slothlet"));| const api = await slothlet({ base: './api' });| const result = await api.slothlet.scope({| context: { userId: 42 },| fn: async () => api.myModule.getUser()| });|})();%% %%example: // CJS usage via slothlet API (inside async function)|const slothlet = require("@cldmv/slothlet");|const api = await slothlet({ base: './api' });|const result = await api.slothlet.scope({| context: { userId: 42 },| fn: async () => api.myModule.getUser()|});%% * @property {function(): Promise.} slothlet.shutdown - Shut down the instance and release all resources. %%sig: (): Promise.%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api' });|await api.slothlet.shutdown();%% %%example: // ESM usage via slothlet API (inside async function)|async function example() {| const { default: slothlet } = await import("@cldmv/slothlet");| const api = await slothlet({ base: './api' });| await api.slothlet.shutdown();|}%% %%example: // CJS usage via slothlet API (top-level)|let slothlet;|(async () => {| ({ slothlet } = await import("@cldmv/slothlet"));| const api = await slothlet({ base: './api' });| await api.slothlet.shutdown();|})();%% %%example: // CJS usage via slothlet API (inside async function)|const slothlet = require("@cldmv/slothlet");|const api = await slothlet({ base: './api' });|await api.slothlet.shutdown();%% diff --git a/tests/vitests/suites/api-manager/restart.test.vitest.mjs b/tests/vitests/suites/api-manager/restart.test.vitest.mjs new file mode 100644 index 000000000..6c43653de --- /dev/null +++ b/tests/vitests/suites/api-manager/restart.test.vitest.mjs @@ -0,0 +1,574 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /tests/vitests/suites/api-manager/restart.test.vitest.mjs + * @Date: 2026-09-28T00:00:00-07:00 (1790578800) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 00:00:00 -07:00 (1790578800) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview `api.slothlet.restart()` (#504) — a clean-slate rebuild from the original config, + * swapped in behind the same `api` reference. + * + * @description + * Contrast with reload(): a restart drops every runtime change (runtime-assigned values, `add()` / + * `remove()` history, runtime hooks, permission rules, event and lifecycle subscriptions) and every + * module's module-scope state, while the api reference and held child references keep working — + * re-pointed at the node at the same path in the new instance. Lifecycle order is + * `restart` → `shutdown` → `init` → `restarted`. + */ + +import path from "node:path"; +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import slothlet from "@cldmv/slothlet"; +import { resolveWrapper } from "#handlers/unified-wrapper"; +import { getMatrixConfigs, TEST_DIRS } from "../../setup/vitest-helper.mjs"; + +const BASE = path.join(path.dirname(TEST_DIRS.API_TEST), "api_test_restart"); +const EXTRA = path.join(path.dirname(TEST_DIRS.API_TEST), "api_test_restart_extra"); + +/** + * Tracker routine config: runs `tracker.shutdown` on every teardown, so a test can see which import + * generation of the fixture was shut down. + * @type {object} + */ +const TRACKER_ROUTINES = { autoRoutines: true, routines: [{ name: "^tracker.shutdown", mode: "shutdown" }] }; + +/** + * Read the fixture's shared module-state record. + * @returns {{imports: number, shutdowns: number[]}} The record. + */ +function fixtureState() { + return globalThis.__slothletRestartFixture; +} + +/** + * Run a call that may throw synchronously (eager) or reject (lazy) as a promise either way. + * @param {Function} fn - The call. + * @returns {Promise} Its settled result. + */ +function settle(fn) { + return Promise.resolve().then(fn); +} + +describe.each(getMatrixConfigs({}))("api.slothlet.restart() (#504) > $name", ({ config }) => { + let api; + let other; + + beforeEach(() => { + globalThis.__slothletRestartFixture = { imports: 0, shutdowns: [] }; + }); + + afterEach(async () => { + if (api?.shutdown) await api.shutdown(); + if (other?.shutdown) await other.shutdown(); + api = null; + other = null; + }); + + /** + * Boot the fixture api for the current matrix config. + * @param {object} [overrides] - Extra config. + * @returns {Promise} The api. + */ + async function create(overrides = {}) { + api = await slothlet({ ...config, base: BASE, silent: true, ...overrides }); + return api; + } + + it("drops a runtime-assigned value that reload() keeps, and re-runs the module's top level (issue example)", async () => { + await create(); + const firstGeneration = api.conn.generation; + + api.conn.handlers = { onSend: "x" }; + await api.slothlet.api.reload(); + // reload() re-imports conn.mjs but re-applies the runtime assignment on top of the new emitter. + expect(api.conn.generation).toBeGreaterThan(firstGeneration); + expect(api.conn.handlers).toEqual({ onSend: "x" }); + + const beforeRestart = api.conn.generation; + await api.slothlet.restart(); + expect(api.conn.handlers).toBeUndefined(); + expect(api.conn.generation).toBeGreaterThan(beforeRestart); + expect(typeof api.conn.on).toBe("function"); + }); + + it("keeps the api reference and returns it", async () => { + await create(); + const ref = api; + const returned = await api.slothlet.restart(); + expect(returned).toBe(ref); + expect(api).toBe(ref); + expect(await api.secret.open()).toBe("open"); + }); + + it("starts module-scope state fresh", async () => { + await create(); + await api.counter.increment(); + await api.counter.increment(); + expect(await api.counter.current()).toBe(2); + await api.slothlet.restart(); + expect(await api.counter.current()).toBe(0); + expect(await api.counter.increment()).toBe(1); + }); + + it("rebuilds under a new instance ID and `self` inside leaves resolves to the new instance", async () => { + await create(); + const oldID = api.slothlet.instanceID; + expect(await api.probe.instanceID()).toBe(oldID); + await api.slothlet.restart(); + const newID = api.slothlet.instanceID; + expect(newID).not.toBe(oldID); + expect(await api.probe.instanceID()).toBe(newID); + expect(await api.probe.connGeneration()).toBe(api.conn.generation); + }); + + it("does not replay runtime add() / remove() history (reload does)", async () => { + await create(); + await api.slothlet.api.add("plugin", EXTRA, { moduleID: "restart-plugin" }); + await api.slothlet.api.remove("secret"); + expect(await api.plugin.ping()).toBe("plugin"); + expect(api.secret).toBeUndefined(); + + await api.slothlet.reload(); + expect(await api.plugin.ping()).toBe("plugin"); + expect(api.secret).toBeUndefined(); + + await api.slothlet.restart(); + expect(api.plugin).toBeUndefined(); + expect(await api.secret.blocked()).toBe("blocked"); + }); + + it("re-points a held child reference to the new instance's node at the same path", async () => { + await create(); + const conn = api.conn; + const increment = api.counter.increment; + await increment(); + await increment(); + const firstGeneration = conn.generation; + + await api.slothlet.restart(); + expect(conn.generation).toBe(api.conn.generation); + expect(conn.generation).toBeGreaterThan(firstGeneration); + // The held leaf now calls the fresh module (count restarts from zero). + expect(await increment()).toBe(1); + expect(await api.counter.current()).toBe(1); + // Writes through the held reference land on the new instance. + conn.handlers = { live: true }; + expect(api.conn.handlers).toEqual({ live: true }); + }); + + it("follows a held reference across two restarts", async () => { + await create(); + const conn = api.conn; + const g1 = conn.generation; + await api.slothlet.restart(); + const g2 = conn.generation; + await api.slothlet.restart(); + const g3 = conn.generation; + expect(g2).toBeGreaterThan(g1); + expect(g3).toBeGreaterThan(g2); + expect(g3).toBe(api.conn.generation); + expect(await api.counter.increment()).toBe(1); + }); + + it("throws RESTART_REFERENCE_UNRESOLVED when a held reference's path no longer exists", async () => { + await create(); + await api.slothlet.api.add("plugin", EXTRA, { moduleID: "restart-plugin" }); + const plugin = api.plugin; + expect(await plugin.ping()).toBe("plugin"); + + await api.slothlet.restart(); + expect(api.plugin).toBeUndefined(); + let caught = null; + try { + await plugin.ping(); + } catch (error) { + caught = error; + } + expect(caught?.code).toBe("RESTART_REFERENCE_UNRESOLVED"); + expect(caught?.message).toContain("plugin"); + }); + + it("drops runtime permission rules while config-declared rules apply", async () => { + await create({ + permissions: { defaultPolicy: "allow", rules: [{ caller: "probe.**", target: "secret.blocked", effect: "deny" }] } + }); + api.slothlet.permissions.addRule({ caller: "probe.**", target: "secret.open", effect: "deny" }); + await expect(settle(() => api.probe.callBlocked())).rejects.toThrow("PERMISSION_DENIED"); + await expect(settle(() => api.probe.callOpen())).rejects.toThrow("PERMISSION_DENIED"); + + await api.slothlet.restart(); + await expect(settle(() => api.probe.callBlocked())).rejects.toThrow("PERMISSION_DENIED"); + expect(await api.probe.callOpen()).toBe("open"); + }); + + it("drops runtime hooks", async () => { + await create({ hook: { enabled: true } }); + const fired = []; + api.slothlet.hook.on("counter.increment:before", () => { + fired.push("before"); + }); + await api.counter.increment(); + expect(fired).toEqual(["before"]); + + await api.slothlet.restart(); + await api.counter.increment(); + expect(fired).toEqual(["before"]); + }); + + it("drops runtime event subscriptions", async () => { + await create(); + const received = []; + api.slothlet.event.on("restart.test", (payload) => received.push(payload)); + await api.slothlet.event.emit("restart.test", 1); + expect(received).toEqual([1]); + + await api.slothlet.restart(); + await api.slothlet.event.emit("restart.test", 2); + expect(received).toEqual([1]); + }); + + it("emits restart → shutdown → init → restarted; runtime subscribers only see the old instance's events", async () => { + const configEvents = []; + /** + * Record a config-declared lifecycle event. + * @param {string} name - Event name. + * @returns {Function} Handler. + */ + const record = (name) => (data) => configEvents.push({ name, data }); + await create({ + lifecycle: { restart: record("restart"), shutdown: record("shutdown"), init: record("init"), restarted: record("restarted") } + }); + const oldID = api.slothlet.instanceID; + expect(configEvents.map((e) => e.name)).toEqual(["init"]); + expect(configEvents[0].data).toMatchObject({ instanceID: oldID, restart: false }); + + const runtimeEvents = []; + for (const name of ["restart", "shutdown", "init", "restarted"]) { + api.slothlet.lifecycle.on(name, () => runtimeEvents.push(name)); + } + + configEvents.length = 0; + await api.slothlet.restart(); + const newID = api.slothlet.instanceID; + expect(configEvents.map((e) => e.name)).toEqual(["restart", "shutdown", "init", "restarted"]); + expect(configEvents[0].data).toEqual({ instanceID: oldID }); + expect(configEvents[1].data).toEqual({ instanceID: oldID, restart: true }); + expect(configEvents[2].data).toEqual({ instanceID: newID, restart: true }); + expect(configEvents[3].data).toEqual({ instanceID: newID, previousInstanceID: oldID }); + expect(runtimeEvents).toEqual(["restart", "shutdown"]); + + // The runtime subscriber went with the old instance. + await api.slothlet.shutdown(); + expect(runtimeEvents).toEqual(["restart", "shutdown"]); + expect(configEvents.at(-1)).toEqual({ name: "shutdown", data: { instanceID: newID, restart: false } }); + api = null; + }); + + it("shuts the old instance down through the normal teardown path", async () => { + await create(TRACKER_ROUTINES); + const oldID = api.slothlet.instanceID; + expect(fixtureState().shutdowns).toEqual([]); + + await api.slothlet.restart(); + const newID = api.slothlet.instanceID; + // The old generation's shutdown routine ran exactly once. + expect(fixtureState().shutdowns).toEqual([1]); + // The old context store is gone; only the new instance's remains for this api. + const ids = api.slothlet.diag.inspect().context.instances.map((entry) => entry.id); + expect(ids).toContain(newID); + expect(ids.some((id) => id === oldID || id.startsWith(`${oldID}__run_`))).toBe(false); + + await api.shutdown(); + expect(fixtureState().shutdowns).toEqual([1, 2]); + api = null; + }); + + it("leaves other instances untouched", async () => { + await create(); + other = await slothlet({ ...config, base: BASE, silent: true }); + await other.counter.increment(); + const otherConn = other.conn; + const otherGeneration = other.conn.generation; + const otherID = other.slothlet.instanceID; + + await api.slothlet.restart(); + expect(other.slothlet.instanceID).toBe(otherID); + expect(await other.probe.instanceID()).toBe(otherID); + expect(await other.counter.current()).toBe(1); + expect(other.conn).toBe(otherConn); + expect(other.conn.generation).toBe(otherGeneration); + expect(await api.probe.instanceID()).toBe(api.slothlet.instanceID); + }); + + it("lets calls already in flight finish on the old implementation", async () => { + await create(); + const startGeneration = api.conn.generation; + const pending = api.probe.slow(150); + await api.slothlet.restart(); + expect(await pending).toBe(startGeneration); + expect(api.conn.generation).toBeGreaterThan(startGeneration); + }); + + it("coalesces concurrent restart() calls into one restart", async () => { + const inits = []; + await create({ lifecycle: { init: (data) => inits.push(data.instanceID) } }); + const [a, b] = await Promise.all([api.slothlet.restart(), api.slothlet.restart()]); + expect(a).toBe(api); + expect(b).toBe(api); + expect(inits).toHaveLength(2); + expect(inits[1]).toBe(api.slothlet.instanceID); + }); + + it("rebuilds from the config as passed at creation, not from later mutations of that object", async () => { + const options = { + ...config, + base: BASE, + silent: true, + permissions: { defaultPolicy: "allow", rules: [{ caller: "probe.**", target: "secret.blocked", effect: "deny" }] } + }; + api = await slothlet(options); + options.base = EXTRA; + options.permissions.rules.length = 0; + options.permissions.rules.push({ caller: "probe.**", target: "secret.open", effect: "deny" }); + + await api.slothlet.restart(); + expect(api.plugin).toBeUndefined(); + await expect(settle(() => api.probe.callBlocked())).rejects.toThrow("PERMISSION_DENIED"); + expect(await api.probe.callOpen()).toBe("open"); + }); + + it("is refused when reload mutations are disabled", async () => { + await create({ api: { mutations: { add: true, remove: true, reload: false } } }); + let caught = null; + try { + await api.slothlet.restart(); + } catch (error) { + caught = error; + } + expect(caught?.code).toBe("INVALID_CONFIG_MUTATIONS_DISABLED"); + }); + + it("restarts a sealed instance into an unsealed one with only the original config's rules", async () => { + await create({ + permissions: { defaultPolicy: "allow", rules: [{ caller: "probe.**", target: "secret.blocked", effect: "deny" }] } + }); + api.slothlet.permissions.addRule({ caller: "probe.**", target: "secret.open", effect: "deny" }); + api.slothlet.permissions.control.seal(); + expect(api.slothlet.permissions.control.sealed).toBe(true); + + await api.slothlet.restart(); + expect(api.slothlet.permissions.control.sealed).toBe(false); + await expect(settle(() => api.probe.callBlocked())).rejects.toThrow("PERMISSION_DENIED"); + expect(await api.probe.callOpen()).toBe("open"); + // Unsealed: the policy can be changed (and sealed) again. + expect(() => api.slothlet.permissions.addRule({ caller: "probe.**", target: "secret.open", effect: "deny" })).not.toThrow(); + api.slothlet.permissions.control.seal(); + expect(api.slothlet.permissions.control.sealed).toBe(true); + }); + + it("leaves restart() reachable from a module when there is no permissions config (built-ins inert)", async () => { + await create(); + const before = api.slothlet.instanceID; + await api.probe.restartInstance(); + expect(api.slothlet.instanceID).not.toBe(before); + expect(await api.probe.instanceID()).toBe(api.slothlet.instanceID); + }); + + it("is host-only by default: a module calling restart() is denied (defaultPolicy allow)", async () => { + await create({ permissions: { defaultPolicy: "allow" } }); + const before = api.slothlet.instanceID; + await expect(settle(() => api.probe.restartInstance())).rejects.toThrow("PERMISSION_DENIED"); + expect(api.slothlet.instanceID).toBe(before); + }); + + it("runs the user's root shutdown during the teardown, also when a permitted module triggered the restart", async () => { + await create({ permissions: { defaultPolicy: "allow", rules: [{ caller: "probe.**", target: "slothlet.restart", effect: "allow" }] } }); + const first = api.slothlet.instanceID; + await api.slothlet.restart(); + expect(fixtureState().userShutdowns).toHaveLength(1); + + const second = api.slothlet.instanceID; + expect(second).not.toBe(first); + await api.probe.restartInstance(); + // The module-triggered teardown ran the user's shutdown too — it was not refused halfway. + expect(fixtureState().userShutdowns).toHaveLength(2); + expect(api.slothlet.instanceID).not.toBe(second); + expect(await api.counter.increment()).toBe(1); + }); + + it("lets the host restart, and a host allow rule lets a trusted module restart", async () => { + await create({ permissions: { defaultPolicy: "allow", rules: [{ caller: "probe.**", target: "slothlet.restart", effect: "allow" }] } }); + const first = api.slothlet.instanceID; + await api.slothlet.restart(); + const second = api.slothlet.instanceID; + expect(second).not.toBe(first); + + await api.probe.restartInstance(); + expect(api.slothlet.instanceID).not.toBe(second); + expect(await api.probe.instanceID()).toBe(api.slothlet.instanceID); + }); +}); + +describe.each(getMatrixConfigs({}))("api.slothlet.restart() (#504) > held references and teardown edges > $name", ({ config }) => { + let api; + + beforeEach(() => { + globalThis.__slothletRestartFixture = { imports: 0, shutdowns: [] }; + }); + + afterEach(async () => { + if (api?.shutdown) await api.shutdown().catch(() => {}); + api = null; + }); + + /** + * Boot the fixture api for the current matrix config. + * @param {object} [overrides] - Extra config. + * @returns {Promise} The api. + */ + async function create(overrides = {}) { + api = await slothlet({ ...config, base: BASE, silent: true, ...overrides }); + return api; + } + + it("forwards reflection on a held reference to the new instance's node", async () => { + await create(); + const conn = api.conn; + await api.slothlet.restart(); + + conn.handlers = { a: 1 }; + expect("handlers" in conn).toBe(true); + expect(Object.keys(conn)).toEqual(Object.keys(api.conn)); + expect(Object.getOwnPropertyDescriptor(conn, "handlers")?.value).toEqual({ a: 1 }); + expect(Object.getOwnPropertyDescriptor(conn, "no-such-key")).toBeUndefined(); + expect(Object.getPrototypeOf(conn)).toBe(Object.getPrototypeOf(api.conn)); + expect(delete conn.handlers).toBe(true); + expect(api.conn.handlers).toBeUndefined(); + }); + + it("keeps a held function reference within its proxy invariants", async () => { + await create(); + const increment = api.counter.increment; + const counter = api.counter; + await api.slothlet.restart(); + + // A function-backed proxy target owns a non-configurable `prototype`; its answers stay tied to it. + expect("prototype" in increment).toBe(true); + expect(Reflect.deleteProperty(increment, "prototype")).toBe(false); + expect(Reflect.ownKeys(increment)).toContain("prototype"); + // A stale `this` is mapped to its live counterpart. + expect(await increment.call(counter)).toBe(1); + + // Once the target is non-extensible, reflection answers from the target itself. + Object.preventExtensions(counter); + expect(() => Reflect.ownKeys(counter)).not.toThrow(); + expect(() => Object.getPrototypeOf(counter)).not.toThrow(); + expect(() => Object.getOwnPropertyDescriptor(counter, "current")).not.toThrow(); + }); + + it("constructs through a held class reference against the new instance", async () => { + await create(); + // Lazy: a namespace read before materialization is a waiting proxy, which is not constructible — + // the same as a fresh read, so materialize the namespace first (before and after the restart). + if (config.mode === "lazy") await api.factory.Widget; + const Widget = api.factory.Widget; + expect(new Widget().generation).toBe(api.conn.generation); + await api.slothlet.restart(); + if (config.mode === "lazy") await api.factory.Widget; + const instance = new Widget(); + expect(instance.generation).toBe(api.conn.generation); + }); + + it("restarts an instance that was already shut down", async () => { + await create(); + const oldID = api.slothlet.instanceID; + await api.slothlet.shutdown(); + await api.slothlet.restart(); + expect(api.slothlet.instanceID).not.toBe(oldID); + expect(await api.counter.increment()).toBe(1); + }); + + it("still rebuilds when a shutdown routine throws, then re-throws its error", async () => { + await create(TRACKER_ROUTINES); + const oldID = api.slothlet.instanceID; + fixtureState().throwOnShutdown = true; + let caught = null; + try { + await api.slothlet.restart(); + } catch (error) { + caught = error; + } + fixtureState().throwOnShutdown = false; + expect(caught?.code).toBe("ROUTINE_FAILED"); + expect(api.slothlet.instanceID).not.toBe(oldID); + expect(await api.counter.increment()).toBe(1); + }); + + it("finishes the internal teardown when the root shutdown hook throws", async () => { + await create(); + const instance = resolveWrapper(api.counter).slothlet; + const oldID = api.slothlet.instanceID; + instance.userHooks.shutdown = () => { + throw new Error("hook failure"); + }; + await expect(api.slothlet.restart()).rejects.toThrow("hook failure"); + const ids = api.slothlet.diag.inspect().context.instances.map((entry) => entry.id); + expect(ids).not.toContain(oldID); + expect(await api.counter.increment()).toBe(1); + }); + + it("refuses restart() on an instance with no retained original config", async () => { + await create(); + resolveWrapper(api.counter).slothlet._originalConfig = null; + let caught = null; + try { + await api.slothlet.restart(); + } catch (error) { + caught = error; + } + expect(caught?.code).toBe("INVALID_CONFIG_NOT_LOADED"); + }); + + it("retains an immutable snapshot of the original config", async () => { + const context = { user: "u" }; + const map = new Map([["k", 1]]); + const extra = { list: [1, { deep: true }], map, bare: Object.create(null) }; + extra.self = extra; + Object.defineProperty(extra, "hidden", { value: 1, enumerable: false }); + extra.bare.flag = true; + await create({ context, extra }); + const snapshot = resolveWrapper(api.counter).slothlet._originalConfig; + + expect(Object.isFrozen(snapshot)).toBe(true); + expect(snapshot.context).toBe(context); + expect(snapshot.extra).not.toBe(extra); + expect(Object.isFrozen(snapshot.extra)).toBe(true); + expect(Object.isFrozen(snapshot.extra.list[1])).toBe(true); + expect(snapshot.extra.self).toBe(snapshot.extra); + expect(snapshot.extra.map).toBe(map); + expect(Object.getPrototypeOf(snapshot.extra.bare)).toBe(null); + expect(snapshot.extra.bare.flag).toBe(true); + expect("hidden" in snapshot.extra).toBe(false); + + await api.slothlet.restart(); + expect(resolveWrapper(api.counter).slothlet.context).toBe(context); + }); + + if (config.mode === "lazy") { + it("forwards a held lazy waiting reference to the new instance", async () => { + await create(); + // Taken before `counter` materializes: a waiting proxy for `counter.increment`. + const increment = api.counter.increment; + await api.slothlet.restart(); + expect(await increment()).toBe(1); + expect(typeof increment.name).toBe("string"); + }); + } +}); diff --git a/tools/ci/i18n-latin-tokens-accepted.json b/tools/ci/i18n-latin-tokens-accepted.json index 8963190d8..ff238dd82 100644 --- a/tools/ci/i18n-latin-tokens-accepted.json +++ b/tools/ci/i18n-latin-tokens-accepted.json @@ -21,6 +21,7 @@ "removeApi", "removeModule", "reload", + "restart", "shutdown", "destroy", "run", @@ -272,6 +273,7 @@ "removeApi", "removeModule", "reload", + "restart", "shutdown", "destroy", "run", diff --git a/types/src/lib/builders/api_builder.d.mts.map b/types/src/lib/builders/api_builder.d.mts.map index d814551c1..dd1cfefac 100644 --- a/types/src/lib/builders/api_builder.d.mts.map +++ b/types/src/lib/builders/api_builder.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"api_builder.d.mts","sourceRoot":"","sources":["../../../../src/lib/builders/api_builder.mjs"],"names":[],"mappings":"AA4NA;;;;;;;;;;;;;GAaG;AACH;IACC,gCAAuC;IAEvC;;;;;;;OAOG;IACH,sBANW,MAAM,EAQhB;IAED;;;;;OAKG;IACH,8BAJW,MAAM,GACJ,OAAO,CAAC,MAAM,CAAC,CA6G3B;IAED;;;;;;;;;;;OAWG;IACH,gCA2rGC;IAED;;;;OAIG;IACH,+BA+CC;IAED;;;;OAIG;IACH,0BAgCC;IAED;;;;OAIG;IACH,4BAsTC;IAED;;;;;OAKG;IACH,8BAiFC;IAED;;;;;OAKG;IACH,uBAgCC;CACD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;8BA/iI6B,2BAA2B"} \ No newline at end of file +{"version":3,"file":"api_builder.d.mts","sourceRoot":"","sources":["../../../../src/lib/builders/api_builder.mjs"],"names":[],"mappings":"AA4NA;;;;;;;;;;;;;GAaG;AACH;IACC,gCAAuC;IAEvC;;;;;;;OAOG;IACH,sBANW,MAAM,EAQhB;IAED;;;;;OAKG;IACH,8BAJW,MAAM,GACJ,OAAO,CAAC,MAAM,CAAC,CA6G3B;IAED;;;;;;;;;;;OAWG;IACH,gCA2sGC;IAED;;;;OAIG;IACH,+BA+CC;IAED;;;;OAIG;IACH,0BAgCC;IAED;;;;OAIG;IACH,4BAsTC;IAED;;;;;OAKG;IACH,8BAiFC;IAED;;;;;OAKG;IACH,uBAgCC;CACD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;8BA/jI6B,2BAA2B"} \ No newline at end of file diff --git a/types/src/lib/handlers/permission-manager.d.mts.map b/types/src/lib/handlers/permission-manager.d.mts.map index 297a5f396..11278dbd2 100644 --- a/types/src/lib/handlers/permission-manager.d.mts.map +++ b/types/src/lib/handlers/permission-manager.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"permission-manager.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/permission-manager.mjs"],"names":[],"mappings":"AAiMA;;;;;;;GAOG;AACH;IACC;;;;OAIG;IACH,yBAHU,MAAM,CAG8B;IAmJ9C;;;;;;OAMG;IACH,sBAJW,MAAM,EAmHhB;IAED;;;;;;;;;;;;;OAaG;IACH,cAVG;QAAqB,MAAM,EAAnB,MAAM;QACO,MAAM,EAAnB,MAAM;QACO,MAAM,EAAnB,MAAM;KACd,kBAAQ,MAAM,GAAC,IAAI,WACX,MAAM,GAAC,IAAI,iBACT,MAAM,CAiDlB;IAED;;;;;;;;;;OAUG;IACH,mBAPW,MAAM,mBACN,MAAM,GAAC,IAAI,GACT,OAAO,CAmCnB;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,mBAZG;QAAqB,MAAM,EAAnB,MAAM;QACO,KAAK,EAAlB,MAAM;QACwB,MAAM,EAApC,MAAM,GAAC,QAAQ,GAAC,OAAO;QACuB,SAAS;KAC/D,kBAAQ,MAAM,GAAC,IAAI,WACX,MAAM,GAAC,IAAI,UACX,MAAM,GAAC,IAAI,GACT,MAAM,CAqClB;IAED;;;;;;;;OAQG;IACH,wBALW,MAAM,mBACN,MAAM,GAAC,IAAI,GACT,OAAO,CAiBnB;IAED;;;;;;;;;;OAUG;IACH,kCALW,MAAM,GAAC,IAAI,aACX,MAAM,mBACN,MAAM,GAAC,IAAI,GACT,MAAM,GAAC,QAAQ,GAAC,OAAO,CA0BnC;IAED;;;;OAIG;IACH,uBAFa,MAAM,CAIlB;IAED;;;;;OAKG;IACH,gCAFa,OAAO,CAOnB;IAuCD;;;;;;;;;;;;;;;;;;;;;;;;;OAyBG;IACH,wBAlBW,MAAM,cAEd;QAAwC,GAAG,EAAnC,CAAS,IAAM,EAAN,MAAM,KAAG,GAAC;QAGQ,OAAO,EAAlC,CAAS,IAAC,EAAD,GAAC,KAAG,GAAC;QACM,MAAM;KAClC,kBAAQ,MAAM,GAAC,IAAI,WACX,CAAS,IAAQ,YAAE,IAAQ,EAAR,KAAK,CAAC,GAAC,CAAC,KAAG,GAAC,GAG7B,IAAI,CA0ChB;IAED;;;;;;;;;;;OAWG;IACH,0BARW,MAAM,mBACN,MAAM,GAAC,IAAI,GACT,OAAO,CAanB;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,0BARW,MAAM,gBACN,GAAC,mBACD,MAAM,GAAC,IAAI,GACT,OAAO,CAiBnB;IAED;;;;;;;OAOG;IACH,mBALW,MAAM,GACJ,OAAO,CAMnB;IAED;;;;;;;;OAQG;IACH,0BALW,MAAM,GACJ,IAAI,CAUhB;IAED;;;;;;;;;;;OAWG;IACH,2BALW,MAAM,GACJ,IAAI,CAUhB;IAED;;;;;;;;;;;OAWG;IACH,uBANW,MAAM,iBACN,MAAM,GACJ,IAAI,CAShB;IAED;;;;;;;;;;;;;;;OAeG;IACH,4BATW,MAAM,cACN,MAAM,mBACN,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,GACT,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,GAAC,CAAA;KAAE,CAAC,CAkCnD;IAED;;;;;;;;;OASG;IACH,yBALW,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,GAAC,CAAA;KAAE,CAAC,GACrC,OAAO,CAAC,IAAI,CAAC,CAMzB;IA4SD;;;;;;;;;;;;;;;OAeG;IACH,wBAXW,MAAM,cACN,MAAM,mBACN,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,YACX;QAAE,QAAQ,CAAC,EAAE,OAAO,CAAA;KAAE,GAAC,IAAI,GAEzB,OAAO,CA2BnB;IAED;;;;;;;;;OASG;IACH,4BANW,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,cAAU,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,WAAS,CAAC,GAAC,IAAI,GAAC,SAAS,mBACvF,MAAM,GAAC,IAAI,GACT,OAAO,CASnB;IAyBD;;;;;;;;;;;;;;;;;;;;;;;;;;OA0BG;IACH,0BAtBW,MAAM,cACN,MAAM,mBACN,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,aACX,kBAAkB,GAAC,IAAI,YAGvB;QAAE,cAAc,CAAC,EAAE,OAAO,CAAC;QAAC,YAAY,CAAC,EAAE,OAAO,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,GAAC,IAAI,GAQrE,OAAO,CA0CnB;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;OA2BG;IACH,sBAXW,MAAM,cACN,MAAM,SACN,KAAK,CAAC,GAAC,CAAC,mBACR,MAAM,GAAC,IAAI,GACT,OAAO,GAAC,OAAO,CAAC,OAAO,CAAC,CA8CpC;IAED;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,8BAbW,MAAM,GAAC,IAAI,YAEX,MAAM,YACN,MAAM,mBACN,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,GACT,OAAO,CAenB;IAED;;;;;;;;;;;;;;;OAeG;IACH,4BAZW,MAAM,GAAC,IAAI,YACX,MAAM,YACN,MAAM,mBACN,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,mBAGX,MAAM,GAAC,IAAI,GACT,OAAO,CAMnB;IAED;;;;;;;OAOG;IACH,4BALW,MAAM,GACJ,KAAK,CAAC,MAAM,CAAC,CAazB;IAED;;;;;;;OAOG;IACH,2BALW,MAAM,GACJ,KAAK,CAAC,MAAM,CAAC,CAYzB;IAED;;;;;;;;OAQG;IACH,8BALW,MAAM,GACJ,KAAK,CAAC,MAAM,CAAC,CAazB;IAED;;;;;;OAMG;IACH,UAJa,IAAI,CAQhB;IAcD;;;;;;;;OAQG;IACH,QAJa,IAAI,CAMhB;IAED;;;;;;;;;;;;;;;;;;;OAmBG;IACH,mCALW,KAAK,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAC,CAAC,GAAC,IAAI,GAAC,SAAS,GAChD,IAAI,CAUhB;IAED;;;;;OAKG;IACH,YAJa,OAAO,CAMnB;IAED;;;;;;OAMG;IACH,WAJa,IAAI,CAQhB;IAED;;;;;;OAMG;IACH,aAJa,OAAO,CAMnB;IAED;;;;;;;;OAQG;IACH,uBAJa,OAAO,CAMnB;IAED;;;;;;;;;OASG;IACH,oBAJa,OAAO,CAMnB;IAED;;;;;;;;;;;;OAYG;IACH,mCAVW,MAAM,GAAC,IAAI,GAAC,SAAS,GACnB,OAAO,CAWnB;IAED;;;;;;;;;;;OAWG;IACH,qBANW,OAAO,GACL,IAAI,CAgBhB;IAED;;;;;;OAMG;IAIH,eAPa,KAAK,CAAC,MAAM,CAAC,CAgBzB;IAED;;;;;;;;OAQG;IACH,2BALW,KAAK,CAAC,MAAM,CAAC,GACX,IAAI,CAShB;IAGD;;;;;;;OAOG;IACH,YAJa,OAAO,CAAC,IAAI,CAAC,CAezB;IA4nBD;;;;;;;OAOG;IACH,cAEC;;CACD;;;;;;;;;;;;;;;;UA5xEa,KAAK,CAAC,GAAC,CAAC,GAAC,IAAI;;;;YACb,MAAM,GAAC,IAAI;;8BAzIK,2BAA2B"} \ No newline at end of file +{"version":3,"file":"permission-manager.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/permission-manager.mjs"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/types/src/lib/handlers/unified-wrapper.d.mts.map b/types/src/lib/handlers/unified-wrapper.d.mts.map index d578f2aa0..69b3534d5 100644 --- a/types/src/lib/handlers/unified-wrapper.d.mts.map +++ b/types/src/lib/handlers/unified-wrapper.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"unified-wrapper.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/unified-wrapper.mjs"],"names":[],"mappings":"AAu7BA;;;;;;;;;;;;;;;;;;GAkBG;AACH,4CAhBW,MAAM,GAAC,MAAM,GACX,OAAO,CAkBnB;AA+6ID;;;;;;;;;;;GAWG;AACH,sCAPW,OAAO,GACL,cAAc,GAAC,IAAI,CAkB/B;AAx+ID;;;;;;;;GAQG;AACH,iCAHU,GAAG,CAAC,MAAM,CAAC,CAG2F;;;;;AAuEhH;;;;;;;;;;GAUG;AACH;IA2UC;;;;;;;;;;;;;;;;OAgBG;IACH,0BAsCC;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,gCAiEC;IA7bD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAoCG;IACH,sBApCW,MAAM,mKAEd;QAAwB,IAAI,EAApB,MAAM;QACU,OAAO,EAAvB,MAAM;QACyB,WAAW;QACvB,eAAe;QAChB,UAAU;QACV,mBAAmB;QACpB,QAAQ;QACD,UAAU;QAGjB,QAAQ;QACR,YAAY;QACE,cAAc;QAI3B,eAAe;KAMzC,EAmOF;IA7QD;;;;;;;;;;;OAWG;IACH,4BAFa,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAC,SAAS,CAKzC;IAgQD;;;;;;;;;;OAUG;IACH,uEAHa,GAAC,CA+Bb;IAED;;;;OAIG;IACH,qBAHa,WAAS,MAAM,GAAC,IAAI,CAKhC;IAiJD;;;;;;;;;;;OAWG;IACH,sBA0CC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,mBAwDC;IAED;;;;;;;;;OASG;IACH,qBAsDC;IAED;;;;OAIG;IACH,uBA4GC;IAED;;;;;;;;;OASG;IACH,qBAEC;IAED;;;;;;;;;OASG;IACH,sBAiBC;IAED;;;;;;;OAOG;IACH,6BAGC;IAED;;;;;;;;;;OAUG;IACH,6BAuiBC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,8BAiQC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,8BA43BC;IAED;;;;;;OAMG;IACH,uCA2zDC;;CACD;8BAj1K6B,2BAA2B"} \ No newline at end of file +{"version":3,"file":"unified-wrapper.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/unified-wrapper.mjs"],"names":[],"mappings":"AAu7BA;;;;;;;;;;;;;;;;;;GAkBG;AACH,4CAhBW,MAAM,GAAC,MAAM,GACX,OAAO,CAkBnB;AAokJD;;;;;;;;;;;GAWG;AACH,sCAPW,OAAO,GACL,cAAc,GAAC,IAAI,CAkB/B;AA7nJD;;;;;;;;GAQG;AACH,iCAHU,GAAG,CAAC,MAAM,CAAC,CAG2F;;;;;AAqNhH;;;;;;;;;;GAUG;AACH;IA8UC;;;;;;;;;;;;;;;;OAgBG;IACH,0BAsCC;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,gCAiEC;IAhcD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAoCG;IACH,sBApCW,MAAM,mKAEd;QAAwB,IAAI,EAApB,MAAM;QACU,OAAO,EAAvB,MAAM;QACyB,WAAW;QACvB,eAAe;QAChB,UAAU;QACV,mBAAmB;QACpB,QAAQ;QACD,UAAU;QAGjB,QAAQ;QACR,YAAY;QACE,cAAc;QAI3B,eAAe;KAMzC,EAsOF;IAhRD;;;;;;;;;;;OAWG;IACH,4BAFa,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAC,SAAS,CAKzC;IAmQD;;;;;;;;;;OAUG;IACH,uEAHa,GAAC,CA+Bb;IAED;;;;OAIG;IACH,qBAHa,WAAS,MAAM,GAAC,IAAI,CAKhC;IAiJD;;;;;;;;;;;OAWG;IACH,sBA0CC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,mBAwDC;IAED;;;;;;;;;OASG;IACH,qBAsDC;IAED;;;;OAIG;IACH,uBA4GC;IAED;;;;;;;;;OASG;IACH,qBAEC;IAED;;;;;;;;;OASG;IACH,sBAiBC;IAED;;;;;;;OAOG;IACH,6BAGC;IAED;;;;;;;;;;OAUG;IACH,6BAuiBC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,8BAiQC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,8BA83BC;IAED;;;;;;OAMG;IACH,uCA6zDC;;CACD;8BAt+K6B,2BAA2B"} \ No newline at end of file diff --git a/types/src/slothlet.d.mts b/types/src/slothlet.d.mts index 2af030f29..a252f8db2 100644 --- a/types/src/slothlet.d.mts +++ b/types/src/slothlet.d.mts @@ -197,7 +197,7 @@ export type SlothletOptions = { */ diagnostics?: boolean | undefined; /** - * - Construction-time lifecycle subscribers, registered on the lifecycle emitter BEFORE the api builds so events emitted during cold-start `buildAPI` (init-time `impl:warning` / `impl:created` / …) are observable. Maps an event name to a handler `function(data, token)` or an array of them; any event name is accepted. Because they are ordinary subscribers, they also receive runtime events afterward — equivalent to calling `api.slothlet.lifecycle.on(event, fn)` for each, but early enough to catch initialization diagnostics. Example: `{ "impl:warning": (d) => log(d), "impl:error": [onError, audit] }`. + * - Construction-time lifecycle subscribers, registered on the lifecycle emitter BEFORE the api builds so events emitted during cold-start `buildAPI` (init-time `impl:warning` / `impl:created` / …) are observable. Maps an event name to a handler `function(data, token)` or an array of them; any event name is accepted. Because they are ordinary subscribers, they also receive runtime events afterward — equivalent to calling `api.slothlet.lifecycle.on(event, fn)` for each, but early enough to catch initialization diagnostics. Because they come from the config, `api.slothlet.restart()` subscribes them again on the new instance, so they also receive the new instance's `init` and `restarted` events (runtime `lifecycle.on` subscribers are dropped with the old instance). Example: `{ "impl:warning": (d) => log(d), "impl:error": [onError, audit] }`. */ lifecycle?: { [x: string]: Function | Function[]; @@ -485,6 +485,7 @@ export type SlothletAPI = { bind: Function; reference?: object | undefined; reload: Function; + restart: Function; run: Function; scope: Function; shutdown: () => Promise; diff --git a/types/src/slothlet.d.mts.map b/types/src/slothlet.d.mts.map index 45bab9426..1e8164086 100644 --- a/types/src/slothlet.d.mts.map +++ b/types/src/slothlet.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"slothlet.d.mts","sourceRoot":"","sources":["../../src/slothlet.mjs"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"slothlet.d.mts","sourceRoot":"","sources":["../../src/slothlet.mjs"],"names":[],"mappings":"AAmtDA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,yBApCuB,CAAC,SAAX,MAAQ,6DAGV,eAAe,GACb,OAAO,CAAC,WAAW,GAAG,CAAC,CAAC,CAwCpC;;kBASS,QAAQ,CAAC;QAAC,QAAQ,EAAE,aAAa,CAAC;YAAC,IAAI,EAAE,MAAM,CAAC;YAAC,IAAI,EAAE,MAAM,CAAA;SAAC,CAAC,CAAC;QAAC,eAAe,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;KAAC,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;eAgD3F,SAAS,GAAC,MAAM;;;;;;;;;;;;qBAEP,MAAM;iBAAO,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cA8BhB,MAAM;eAAS,CAAC,QAAQ,GAAC,SAAS,GAAC,UAAU,GAAC,SAAS,CAAC;oBAAc,OAAO;gBAAU,CAAC,OAAO,GAAC,OAAO,CAAC;kBAAY,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;eAevI,KAAK,CAAC;YAAC,IAAI,EAAC,MAAM,CAAC;YAAA,IAAI,EAAC,MAAM,CAAC;YAAA,QAAQ,EAAC,MAAM,CAAA;SAAC,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;aA8BxD,MAAY,IAAI;;;;cAChB,MAAY,IAAI;;;;cAE3B;QAAgE,GAAG,EAAxD,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAC,SAAS,CAAC,CAAC;QACzB,GAAG,EAC/B;YAAkC,GAAG;YACH,MAAM;YACN,MAAM;YACN,MAAM;YACR,OAAO,EACvC;gBAA0C,QAAQ;gBACR,IAAI;gBACJ,SAAS;gBACT,UAAU;gBACV,YAAY;gBACZ,aAAa;gBACb,iBAAiB;gBACjB,mBAAmB;gBACnB,cAAc;aACxD;SAAA;QAA4B,OAAO,EACnC;YAAsC,GAAG;YACO,OAAO,EAA5C,MAAY,MAAM;YACS,GAAG;YACH,KAAK;YACL,GAAG;SACzC;QAA6B,IAAI;;;;;;;;6BAEV,MAAM;;;;yCACN,MAAM,EAAE;;;;;;;;;;;;;;;;;4BAIR,MAAM;;;;kCACN,MAAM;;;;;;;;6BAEN,MAAM;;;;;;;;;;;;;;;;;qCAIN,eAAe;;QACV,IAAI,EAChC;YAAmC,KAAK;YACL,OAAO;YACP,cAAc;YACd,MAAM;YACN,aAAa;YACb,IAAI;YACJ,GAAG;YACH,EAAE;YACJ,GAAG,EACpC;gBAAsC,OAAO,EAAlC,OAAO;gBACqB,MAAM;gBACN,OAAO;aAC9C;YAAmC,MAAM;YACN,kBAAkB;SACrD;QAA4B,KAAK,EACjC;YAAoC,EAAE;YACF,IAAI;YACJ,GAAG;YACH,IAAI;YACJ,YAAY;YACd,KAAK,EACvC;gBAA0C,GAAG;gBACH,MAAM;aAChD;YAAoC,QAAQ;YACR,OAAO;SAC3C;QAA4B,SAAS,EACrC;YAAwC,GAAG;YACH,EAAE;SAC1C;QAA4B,WAAW,EACvC;YAAoD,GAAG,EAA5C,MAAY,MAAM;YACY,YAAY,EAA1C,OAAO;YAC0C,IAAI,EAArD,MAAY,OAAO,CAAE,IAAI,CAAC;SACrC;QAA4B,QAAQ,EACpC;YAAsD,MAAM,EAAjD,MAAY,MAAM,GAAC,IAAI;YACK,GAAG;YACH,MAAM;YACN,MAAM;YACN,SAAS;YACM,IAAI,EAA/C,MAAY,MAAM,GAAC,IAAI;YACK,GAAG;YACH,MAAM;YACN,SAAS;SAChD;QAA4B,KAAK,EACjC;YAAoC,GAAG;SACvC;QAA4B,SAAS,EACrC;YAAwC,GAAG;YACH,UAAU;SAClD;QAA4B,WAAW,EACvC;YAAwC,OAAO,EAC/C;gBAAiD,OAAO,EAA7C,OAAO;gBACwC,MAAM,EAArD,MAAY,IAAI;gBAC+B,OAAO,EAAtD,MAAY,IAAI;gBACsB,iBAAiB,EAAvD,OAAO;gBAC+C,UAAU,EAAhE,CAAS,IAAO,EAAP,OAAO,KAAG,IAAI;gBACwB,IAAI,EAAnD,MAAY,IAAI;gBACsB,MAAM,EAA5C,OAAO;aAClB;SAAA;QAA4B,UAAU,EACtC;YAAyC,IAAI;YACJ,UAAU;YACV,UAAU;YACV,kBAAkB;YAClB,kBAAkB;SAC3D;QAA8B,UAAU;QACV,IAAI;QACL,SAAS;QACR,MAAM;QACN,OAAO;QACP,GAAG;QACH,KAAK;QACa,QAAQ,EAA7C,MAAY,OAAO,CAAE,IAAI,CAAC;KACvC;;gCAh6D6D,wBAAwB"} \ No newline at end of file