Layr8.SpaceWatcher (layr8 v0.2.10)

Copy Markdown View Source

Polls two independent signals about "my MCP tool surface" — the caller's held wallet (VG/credential set) and the Space's live MCP resource set — and calls back when either one's signature changes.

Cross-language contract: ~/Developments/contracts/sdk-space-watch.md (layr8/contracts). @layr8/sdk's SpaceWatcher (src/space-watch.ts) is the same abstraction on the Node side; both exist so a caller sees a change at the same latency regardless of which SDK it's built on. Before this module, the broker (mcp/src/broker/daemon.ts's watchSpace/1) and Loom (Loom.Mcp.Catalog) each grew their own bespoke version of this loop (LAYR8-869/DEBT-055).

What this module does NOT know

fetch_wallet and fetch_resources return {:ok, enumerable} / {:error, reason}, where enumerable's elements are whatever the CALLER's domain calls a credential or a resource (a credential id, a resource DID, a content hash — anything comparable with Kernel.</2). This module computes an order-independent signature over that enumerable and nothing more — it never inspects the shape of an element, so it has no dependency on cloud-node's credential shape or discovery-service's card shape. Translating from those domains into a flat enumerable of comparable identifiers is the caller's job (see Loom.Mcp.Catalog for a worked example).

Change semantics

  • The signature is the sorted, deduped list of elements. Order in the fetched enumerable never matters.
  • on_wallet_change / on_resources_change fire with the freshly fetched value ONLY when the signature differs from the last accepted one.
  • The first successful poll of each signal seeds the baseline silently — a cold start is not a change, so the callback is never invoked for it.
  • A fetch error (or an exception raised by the fetch function) is logged and otherwise ignored: the last accepted signature is retained, nothing is notified, and the process does not crash. A transient failure must never read as "everything disappeared."
  • Resources debounce an empty result: once a non-empty baseline is established, an empty poll is accepted (and can notify) only after TWO CONSECUTIVE empty polls — mirrors the broker's acceptsDiscovery. Growing the set, or shrinking it to a still-non-empty set, always applies immediately. Wallet never debounces empty: a wallet answering "nothing held" is a different failure shape than a directory blip, and callers must be able to trust it immediately.

Manual refresh

refresh_wallet/1 and refresh_resources/1 cast an immediate extra poll of that signal without disturbing the regular interval — the generalization of Loom.Mcp.Catalog.refresh/1, which had no resource-poll analog and no wallet side at all. Both are safe to call even when no watcher process is registered: GenServer.cast/2 to a name with no live process returns :ok without raising, so a caller (or a test that never started the watcher) can call them unconditionally.

Usage

{:ok, _pid} =
  Layr8.SpaceWatcher.start_link(
    fetch_wallet: fn -> Loom.Wallet.list_grants(holder_did) end,
    fetch_resources: fn -> Loom.Mcp.Directory.list_accounts() end,
    on_wallet_change: fn _new_wallet -> Loom.Mcp.Catalog.refresh() end,
    on_resources_change: fn new_resources -> handle_resources(new_resources) end,
    name: MyApp.SpaceWatcher
  )

Summary

Functions

Returns a specification to start this module under a supervisor.

Forces an immediate extra resource poll, in addition to the regular interval.

Forces an immediate extra wallet poll, in addition to the regular interval.

Starts the watcher.

Types

change_fun()

@type change_fun() :: (term() -> any())

fetch_fun()

@type fetch_fun() :: (-> {:ok, Enumerable.t()} | {:error, term()})

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

refresh_resources(server \\ __MODULE__)

@spec refresh_resources(GenServer.server()) :: :ok

Forces an immediate extra resource poll, in addition to the regular interval.

Async (GenServer.cast/2) and safe to call even when no watcher process is registered under server — it returns :ok either way.

refresh_wallet(server \\ __MODULE__)

@spec refresh_wallet(GenServer.server()) :: :ok

Forces an immediate extra wallet poll, in addition to the regular interval.

Async (GenServer.cast/2) and safe to call even when no watcher process is registered under server — it returns :ok either way.

start_link(opts \\ [])

@spec start_link(keyword()) :: GenServer.on_start()

Starts the watcher.

Options

  • :fetch_wallet (required) — zero-arg function returning {:ok, enumerable} or {:error, reason} for the caller's current wallet/credential set.
  • :fetch_resources (required) — zero-arg function returning {:ok, enumerable} or {:error, reason} for the caller's current resource set.
  • :on_wallet_change — 1-arity function called with the fresh wallet value when its signature changes. Defaults to a no-op.
  • :on_resources_change — 1-arity function called with the fresh resource value when its signature changes. Defaults to a no-op.
  • :wallet_poll_ms — poll interval for the wallet signal. Defaults to 15000.
  • :resource_poll_ms — poll interval for the resource signal. Defaults to 60000.
  • :now — injectable clock, a zero-arg function. Not used to gate the poll/debounce logic itself (which is purely event-counted, so it is already exercisable at millisecond scale by passing small :wallet_poll_ms / :resource_poll_ms values in tests — mirroring Loom.Mcp.Catalog's refresh_ms/boot_delay_ms injection). Reserved for a consumer or a future extension that wants a monotonic timestamp; defaults to &System.monotonic_time/0.
  • :name — GenServer name. Defaults to __MODULE__.