summaryrefslogtreecommitdiffhomepage
path: root/packages/surface-loaded-extensions/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-12 16:36:10 +0900
committerAdam Malczewski <[email protected]>2026-06-12 16:36:10 +0900
commit6689eb51b467d8e370f31495840d88661f978168 (patch)
treed4234bfde98754dec48d2eff8594461780e609d4 /packages/surface-loaded-extensions/src
parentb3d270803f95db2467e20bb742aa42faf6867f91 (diff)
downloaddispatch-6689eb51b467d8e370f31495840d88661f978168.tar.gz
dispatch-6689eb51b467d8e370f31495840d88661f978168.zip
feat(cache-warming): lifecycle CR-4 — default-off, fresh nextWarmAt, conversation close (+CR-1 table, CR-2 scope)
CR-4a: warming defaults OFF (opt-in per conversation); re-enabling restores the persisted interval. CR-4b: re-arm BEFORE surface notify so post-warm updates carry the FUTURE nextWarmAt; turnSettled/turnStarted now also push (fresh schedule after seal, null while generating). CR-4c: POST /conversations/:id/close — per-turn AbortController wired to the kernel runTurn signal (partial persist + normal seal, done.reason "aborted"), new conversationClosed hook, cache-warming disables sync + persists OFF. Disconnect/chat.unsubscribe semantics unchanged. CR-4d: no change needed — initial surface echo already at HEAD (stale up2 boot on the FE probe). CR-1: loaded-extensions emits a single custom rendererId:"table" field (TablePayload exported; Name|Version|Trust|Activation, all trust tiers). CR-2: SurfaceCatalogEntry.scope?: "global"|"conversation" on both surfaces. Contracts: ui-contract 0.1.0→0.2.0, transport-contract 0.8.0→0.9.0 (additive). 907 tests pass (+13); live-verified against bin/up (warms @5s with future nextWarmAt; mid-turn close → abortedTurn:true + done.reason aborted). Courier: frontend-cache-warming-lifecycle-handoff.md.
Diffstat (limited to 'packages/surface-loaded-extensions/src')
-rw-r--r--packages/surface-loaded-extensions/src/extension.ts1
-rw-r--r--packages/surface-loaded-extensions/src/index.ts2
-rw-r--r--packages/surface-loaded-extensions/src/spec.test.ts84
-rw-r--r--packages/surface-loaded-extensions/src/spec.ts46
4 files changed, 87 insertions, 46 deletions
diff --git a/packages/surface-loaded-extensions/src/extension.ts b/packages/surface-loaded-extensions/src/extension.ts
index abef4b6..20abdec 100644
--- a/packages/surface-loaded-extensions/src/extension.ts
+++ b/packages/surface-loaded-extensions/src/extension.ts
@@ -27,6 +27,7 @@ export function createLoadedExtensionsExtension(): Extension {
id: "loaded-extensions",
region: "side",
title: "Loaded Extensions",
+ scope: "global",
},
getSpec() {
return buildLoadedExtensionsSpec(host.getExtensions());
diff --git a/packages/surface-loaded-extensions/src/index.ts b/packages/surface-loaded-extensions/src/index.ts
index bc10dc5..ae11e02 100644
--- a/packages/surface-loaded-extensions/src/index.ts
+++ b/packages/surface-loaded-extensions/src/index.ts
@@ -1,2 +1,2 @@
export { createLoadedExtensionsExtension, manifest } from "./extension.js";
-export { buildLoadedExtensionsSpec } from "./spec.js";
+export { buildLoadedExtensionsSpec, TABLE_RENDERER_ID, type TablePayload } from "./spec.js";
diff --git a/packages/surface-loaded-extensions/src/spec.test.ts b/packages/surface-loaded-extensions/src/spec.test.ts
index 9c1aa6a..bc31b9e 100644
--- a/packages/surface-loaded-extensions/src/spec.test.ts
+++ b/packages/surface-loaded-extensions/src/spec.test.ts
@@ -1,63 +1,78 @@
import type { Manifest } from "@dispatch/kernel";
-import type { StatField } from "@dispatch/ui-contract";
+import type { CustomField, StatField } from "@dispatch/ui-contract";
import { describe, expect, it } from "vitest";
-import { buildLoadedExtensionsSpec } from "./spec.js";
+import { buildLoadedExtensionsSpec, TABLE_RENDERER_ID, type TablePayload } from "./spec.js";
-function fakeManifest(id: string, name: string, version: string): Manifest {
+function fakeManifest(
+ id: string,
+ name: string,
+ version: string,
+ extra: Partial<Manifest> = {},
+): Manifest {
return {
id,
name,
version,
apiVersion: "^0.1.0",
trust: "bundled",
+ ...extra,
};
}
+function tablePayload(field: unknown): TablePayload {
+ const custom = field as CustomField;
+ expect(custom.kind).toBe("custom");
+ expect(custom.rendererId).toBe(TABLE_RENDERER_ID);
+ return custom.payload as TablePayload;
+}
+
describe("buildLoadedExtensionsSpec", () => {
- it("returns a count stat of '0' and no extension stats for empty manifests", () => {
+ it("returns a count stat of '0' and an empty table for empty manifests", () => {
const spec = buildLoadedExtensionsSpec([]);
expect(spec.id).toBe("loaded-extensions");
expect(spec.region).toBe("side");
expect(spec.title).toBe("Loaded Extensions");
- expect(spec.fields).toHaveLength(1);
+ expect(spec.fields).toHaveLength(2);
expect(spec.fields[0]).toEqual({
kind: "stat",
label: "Loaded",
value: "0",
});
+ expect(tablePayload(spec.fields[1]).rows).toEqual([]);
});
- it("returns a count stat plus one stat per manifest in order", () => {
+ it("returns a count stat plus ONE table field with a row per manifest (CR-1)", () => {
const manifests = [
fakeManifest("alpha", "Alpha", "1.0.0"),
- fakeManifest("beta", "Beta", "2.3.1"),
- fakeManifest("gamma", "Gamma", "0.5.0"),
+ fakeManifest("beta", "Beta", "2.3.1", { trust: "external", activation: "lazy" }),
+ fakeManifest("gamma", "Gamma", "0.5.0", { trust: "local" }),
];
const spec = buildLoadedExtensionsSpec(manifests);
- expect(spec.fields).toHaveLength(4);
+ expect(spec.fields).toHaveLength(2);
expect(spec.fields[0]).toEqual({
kind: "stat",
label: "Loaded",
value: "3",
});
- expect(spec.fields[1]).toEqual({
- kind: "stat",
- label: "Alpha",
- value: "1.0.0",
- });
- expect(spec.fields[2]).toEqual({
- kind: "stat",
- label: "Beta",
- value: "2.3.1",
- });
- expect(spec.fields[3]).toEqual({
- kind: "stat",
- label: "Gamma",
- value: "0.5.0",
- });
+
+ const payload = tablePayload(spec.fields[1]);
+ expect(payload.columns).toEqual(["Name", "Version", "Trust", "Activation"]);
+ expect(payload.rows).toEqual([
+ ["Alpha", "1.0.0", "bundled", "eager"],
+ ["Beta", "2.3.1", "external", "lazy"],
+ ["Gamma", "0.5.0", "local", "eager"],
+ ]);
+ });
+
+ it("every row aligns cell-for-cell to the columns", () => {
+ const spec = buildLoadedExtensionsSpec([fakeManifest("a", "A", "1.0.0")]);
+ const payload = tablePayload(spec.fields[1]);
+ for (const row of payload.rows) {
+ expect(row).toHaveLength(payload.columns.length);
+ }
});
it("preserves input order of manifests", () => {
@@ -68,8 +83,8 @@ describe("buildLoadedExtensionsSpec", () => {
const spec = buildLoadedExtensionsSpec(manifests);
- expect((spec.fields[1] as StatField).label).toBe("Z Last");
- expect((spec.fields[2] as StatField).label).toBe("A First");
+ const payload = tablePayload(spec.fields[1]);
+ expect(payload.rows.map((r) => r[0])).toEqual(["Z Last", "A First"]);
});
it("sets the surface id, region, and title correctly", () => {
@@ -80,15 +95,14 @@ describe("buildLoadedExtensionsSpec", () => {
expect(spec.title).toBe("Loaded Extensions");
});
- it("uses manifest.name as label and manifest.version as value", () => {
- const manifests = [fakeManifest("my-ext", "My Extension", "3.2.1")];
-
- const spec = buildLoadedExtensionsSpec(manifests);
+ it("defaults a missing activation to 'eager' (the declared manifest default)", () => {
+ const spec = buildLoadedExtensionsSpec([fakeManifest("my-ext", "My Extension", "3.2.1")]);
+ const payload = tablePayload(spec.fields[1]);
+ expect(payload.rows[0]).toEqual(["My Extension", "3.2.1", "bundled", "eager"]);
+ });
- expect(spec.fields[1]).toEqual({
- kind: "stat",
- label: "My Extension",
- value: "3.2.1",
- });
+ it("the count stat remains a plain stat (graceful-skip clients still see it)", () => {
+ const spec = buildLoadedExtensionsSpec([fakeManifest("a", "A", "1.0.0")]);
+ expect((spec.fields[0] as StatField).kind).toBe("stat");
});
});
diff --git a/packages/surface-loaded-extensions/src/spec.ts b/packages/surface-loaded-extensions/src/spec.ts
index bd3dd56..72e8d41 100644
--- a/packages/surface-loaded-extensions/src/spec.ts
+++ b/packages/surface-loaded-extensions/src/spec.ts
@@ -1,25 +1,51 @@
import type { Manifest } from "@dispatch/kernel";
-import type { StatField, SurfaceSpec } from "@dispatch/ui-contract";
+import type { CustomField, StatField, SurfaceSpec } from "@dispatch/ui-contract";
+
+/**
+ * The typed payload of the `rendererId: "table"` custom field (CR-1). Exported
+ * so a client renderer narrows `CustomField.payload` via this symbol instead of
+ * a blind `unknown`. Each row aligns cell-for-cell to `columns`.
+ */
+export interface TablePayload {
+ readonly columns: readonly string[];
+ readonly rows: ReadonlyArray<ReadonlyArray<string | number | boolean>>;
+}
+
+/** The renderer id clients dispatch on for the extensions table. */
+export const TABLE_RENDERER_ID = "table";
/**
* Pure core — builds the SurfaceSpec for the loaded-extensions surface.
* Zero I/O, zero ambient state. Decision logic only: input → output.
+ *
+ * Emits a "Loaded" count stat plus ONE `custom`/"table" field enumerating EVERY
+ * loaded extension (all trust tiers) as real columns (CR-1). A client without a
+ * "table" renderer gracefully skips the field and still sees the count.
*/
export function buildLoadedExtensionsSpec(manifests: readonly Manifest[]): SurfaceSpec {
- const fields: StatField[] = [{ kind: "stat", label: "Loaded", value: String(manifests.length) }];
+ const count: StatField = { kind: "stat", label: "Loaded", value: String(manifests.length) };
+
+ const payload: TablePayload = {
+ columns: ["Name", "Version", "Trust", "Activation"],
+ rows: manifests.map((manifest) => [
+ manifest.name,
+ manifest.version,
+ manifest.trust,
+ // Activation is optional in the manifest; "eager" is the declared default.
+ manifest.activation ?? "eager",
+ ]),
+ };
- for (const manifest of manifests) {
- fields.push({
- kind: "stat",
- label: manifest.name,
- value: manifest.version,
- });
- }
+ const table: CustomField = {
+ kind: "custom",
+ rendererId: TABLE_RENDERER_ID,
+ payload,
+ };
return {
id: "loaded-extensions",
region: "side",
title: "Loaded Extensions",
- fields,
+ fields: [count, table],
};
}