summaryrefslogtreecommitdiffhomepage
path: root/packages/surface-registry
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-06 18:55:53 +0900
committerAdam Malczewski <[email protected]>2026-06-06 18:55:53 +0900
commit22936857685c318b71752d625808100b1a96e63e (patch)
tree5e10a73d616c206e3820a8d8568e5f3d4c8a302e /packages/surface-registry
parent969afc45f895230fe3da1c737f18e64452efc8f2 (diff)
downloaddispatch-22936857685c318b71752d625808100b1a96e63e.tar.gz
dispatch-22936857685c318b71752d625808100b1a96e63e.zip
feat(frontend,wire): surface system (FE slice 1) + @dispatch/wire types-only split (B2)
FE slice 1 — backend-declared, frontend-agnostic surface system (verified live): new types-only @dispatch/ui-contract (SurfaceSpec / field kinds / region / ActionRef / catalog), surface-registry (typed service handle), transport-ws (Bun WS :24205, path-agnostic upgrade), surface-loaded-extensions (first real surface); kernel HostAPI.getExtensions; host-bin wiring; bin/up. Harness: retire AGENTS 'backend only', ORCHESTRATOR §3/§7/§8, frontend-design.md locked. B2 — wire-types split (chat-slice prerequisite): new types-only @dispatch/wire single-sources the wire ABI (AgentEvent + 11 variants; conversation model Chunk/ChatMessage/Role/TurnId/StepId + 6 chunk variants; Usage) with zero @dispatch/* deps. @dispatch/kernel re-exports via shims so its public surface is byte-identical (zero consumer blast radius). transport-contract re-exports AgentEvent from @dispatch/wire and drops its @dispatch/kernel dependency, so HTTP clients (the web frontend) consume the wire without the kernel runtime. tsc -b + biome clean; 460 vitest + 77 bun pass.
Diffstat (limited to 'packages/surface-registry')
-rw-r--r--packages/surface-registry/package.json12
-rw-r--r--packages/surface-registry/src/extension.ts23
-rw-r--r--packages/surface-registry/src/index.ts4
-rw-r--r--packages/surface-registry/src/registry.test.ts122
-rw-r--r--packages/surface-registry/src/registry.ts80
-rw-r--r--packages/surface-registry/src/service.ts4
-rw-r--r--packages/surface-registry/tsconfig.json6
7 files changed, 251 insertions, 0 deletions
diff --git a/packages/surface-registry/package.json b/packages/surface-registry/package.json
new file mode 100644
index 0000000..16b0c4c
--- /dev/null
+++ b/packages/surface-registry/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@dispatch/surface-registry",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*",
+ "@dispatch/ui-contract": "workspace:*"
+ }
+}
diff --git a/packages/surface-registry/src/extension.ts b/packages/surface-registry/src/extension.ts
new file mode 100644
index 0000000..6d0ce22
--- /dev/null
+++ b/packages/surface-registry/src/extension.ts
@@ -0,0 +1,23 @@
+import type { Extension, Manifest } from "@dispatch/kernel";
+import { createSurfaceRegistry } from "./registry.js";
+import { surfaceRegistryHandle } from "./service.js";
+
+export const manifest: Manifest = {
+ id: "surface-registry",
+ name: "Surface Registry",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ activation: "eager",
+ contributes: { services: ["surface-registry/registry"] },
+};
+
+export function createSurfaceRegistryExtension(): Extension {
+ return {
+ manifest,
+ activate(host) {
+ const registry = createSurfaceRegistry();
+ host.provideService(surfaceRegistryHandle, registry);
+ },
+ };
+}
diff --git a/packages/surface-registry/src/index.ts b/packages/surface-registry/src/index.ts
new file mode 100644
index 0000000..cdfcf7e
--- /dev/null
+++ b/packages/surface-registry/src/index.ts
@@ -0,0 +1,4 @@
+export { createSurfaceRegistryExtension, manifest } from "./extension.js";
+export type { SurfaceProvider, SurfaceRegistry } from "./registry.js";
+export { createSurfaceRegistry } from "./registry.js";
+export { surfaceRegistryHandle } from "./service.js";
diff --git a/packages/surface-registry/src/registry.test.ts b/packages/surface-registry/src/registry.test.ts
new file mode 100644
index 0000000..c47c979
--- /dev/null
+++ b/packages/surface-registry/src/registry.test.ts
@@ -0,0 +1,122 @@
+import type { SurfaceCatalogEntry, SurfaceSpec } from "@dispatch/ui-contract";
+import { describe, expect, it } from "vitest";
+import type { SurfaceProvider } from "./registry.js";
+import { createSurfaceRegistry } from "./registry.js";
+
+function fakeProvider(id: string, title?: string): SurfaceProvider {
+ const catalogEntry: SurfaceCatalogEntry = {
+ id,
+ region: "default",
+ title: title ?? `Surface ${id}`,
+ };
+ return {
+ catalogEntry,
+ getSpec(): SurfaceSpec {
+ return {
+ id,
+ region: "default",
+ title: catalogEntry.title,
+ fields: [],
+ };
+ },
+ invoke() {},
+ };
+}
+
+describe("createSurfaceRegistry", () => {
+ describe("register + getCatalog", () => {
+ it("returns the entry after registration", () => {
+ const registry = createSurfaceRegistry();
+ registry.register(fakeProvider("a", "Surface A"));
+
+ const catalog = registry.getCatalog();
+ expect(catalog).toHaveLength(1);
+ expect(catalog[0]).toEqual({
+ id: "a",
+ region: "default",
+ title: "Surface A",
+ });
+ });
+
+ it("returns entries for multiple providers", () => {
+ const registry = createSurfaceRegistry();
+ registry.register(fakeProvider("a"));
+ registry.register(fakeProvider("b"));
+
+ const catalog = registry.getCatalog();
+ expect(catalog).toHaveLength(2);
+ expect(catalog.map((e) => e.id)).toEqual(["a", "b"]);
+ });
+ });
+
+ describe("getSurface", () => {
+ it("returns the provider for a known id", () => {
+ const registry = createSurfaceRegistry();
+ const provider = fakeProvider("x");
+ registry.register(provider);
+
+ expect(registry.getSurface("x")).toBe(provider);
+ });
+
+ it("returns undefined for an unknown id", () => {
+ const registry = createSurfaceRegistry();
+ expect(registry.getSurface("nonexistent")).toBeUndefined();
+ });
+ });
+
+ describe("disposer", () => {
+ it("removes the provider from catalog and lookup", () => {
+ const registry = createSurfaceRegistry();
+ const dispose = registry.register(fakeProvider("a"));
+
+ expect(registry.getCatalog()).toHaveLength(1);
+ expect(registry.getSurface("a")).toBeDefined();
+
+ dispose();
+
+ expect(registry.getCatalog()).toHaveLength(0);
+ expect(registry.getSurface("a")).toBeUndefined();
+ });
+
+ it("is idempotent — calling dispose twice is safe", () => {
+ const registry = createSurfaceRegistry();
+ const dispose = registry.register(fakeProvider("a"));
+
+ dispose();
+ dispose();
+
+ expect(registry.getCatalog()).toHaveLength(0);
+ });
+
+ it("does not remove a replacement provider with the same id", () => {
+ const registry = createSurfaceRegistry();
+ const first = fakeProvider("a", "First");
+ const second = fakeProvider("a", "Second");
+
+ const disposeFirst = registry.register(first);
+ registry.register(second);
+
+ disposeFirst();
+
+ // The second provider should still be registered
+ expect(registry.getSurface("a")).toBe(second);
+ expect(registry.getCatalog()).toHaveLength(1);
+ expect(registry.getCatalog()[0]?.title).toBe("Second");
+ });
+ });
+
+ describe("duplicate-id behavior (last-wins)", () => {
+ it("replaces an existing provider when registering the same id", () => {
+ const registry = createSurfaceRegistry();
+ const first = fakeProvider("a", "First");
+ const second = fakeProvider("a", "Second");
+
+ registry.register(first);
+ registry.register(second);
+
+ expect(registry.getSurface("a")).toBe(second);
+ expect(registry.getCatalog()).toHaveLength(1);
+ expect(registry.getCatalog()[0]?.title).toBe("Second");
+ });
+ });
+});
diff --git a/packages/surface-registry/src/registry.ts b/packages/surface-registry/src/registry.ts
new file mode 100644
index 0000000..b1c8116
--- /dev/null
+++ b/packages/surface-registry/src/registry.ts
@@ -0,0 +1,80 @@
+import type { SurfaceCatalog, SurfaceCatalogEntry, SurfaceSpec } from "@dispatch/ui-contract";
+
+/**
+ * What a surface-contributing extension registers with the surface registry.
+ * Each provider owns one surface identified by its catalog entry id.
+ */
+export interface SurfaceProvider {
+ /** Discovery metadata for the surface catalog. */
+ readonly catalogEntry: SurfaceCatalogEntry;
+
+ /** Build the current surface spec (may be async for dynamic surfaces). */
+ getSpec(): SurfaceSpec | Promise<SurfaceSpec>;
+
+ /** Run a backend action by id with an optional payload. */
+ invoke(actionId: string, payload?: unknown): void | Promise<void>;
+
+ /**
+ * Optional: subscribe to spec changes. Returns an unsubscribe disposer.
+ * When the spec changes, the caller should re-fetch via getSpec() and push.
+ */
+ subscribe?(onChange: () => void): () => void;
+}
+
+/**
+ * The surface registry service — the interface other extensions obtain via
+ * `host.getService(surfaceRegistryHandle)`.
+ */
+export interface SurfaceRegistry {
+ /**
+ * Register a surface provider. Returns an unregister disposer.
+ * If a provider with the same id is already registered, the new one
+ * replaces it (last-wins semantics).
+ */
+ register(provider: SurfaceProvider): () => void;
+
+ /** Return discovery metadata for all currently registered providers. */
+ getCatalog(): SurfaceCatalog;
+
+ /** Look up a provider by its surface id. */
+ getSurface(id: string): SurfaceProvider | undefined;
+}
+
+/**
+ * Create a pure in-memory surface registry. No I/O, no ambient state —
+ * the decision logic is a plain Map behind the SurfaceRegistry interface.
+ */
+export function createSurfaceRegistry(): SurfaceRegistry {
+ const providers = new Map<string, SurfaceProvider>();
+
+ return {
+ register(provider: SurfaceProvider): () => void {
+ const id = provider.catalogEntry.id;
+ providers.set(id, provider);
+
+ let disposed = false;
+ return () => {
+ if (!disposed) {
+ disposed = true;
+ // Only delete if the current entry is still this provider
+ // (another register with the same id may have replaced it).
+ if (providers.get(id) === provider) {
+ providers.delete(id);
+ }
+ }
+ };
+ },
+
+ getCatalog(): SurfaceCatalog {
+ const entries: SurfaceCatalogEntry[] = [];
+ for (const provider of providers.values()) {
+ entries.push(provider.catalogEntry);
+ }
+ return entries;
+ },
+
+ getSurface(id: string): SurfaceProvider | undefined {
+ return providers.get(id);
+ },
+ };
+}
diff --git a/packages/surface-registry/src/service.ts b/packages/surface-registry/src/service.ts
new file mode 100644
index 0000000..a43c155
--- /dev/null
+++ b/packages/surface-registry/src/service.ts
@@ -0,0 +1,4 @@
+import { defineService } from "@dispatch/kernel";
+import type { SurfaceRegistry } from "./registry.js";
+
+export const surfaceRegistryHandle = defineService<SurfaceRegistry>("surface-registry/registry");
diff --git a/packages/surface-registry/tsconfig.json b/packages/surface-registry/tsconfig.json
new file mode 100644
index 0000000..e430ba9
--- /dev/null
+++ b/packages/surface-registry/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [{ "path": "../kernel" }, { "path": "../ui-contract" }]
+}