diff options
| author | Adam Malczewski <[email protected]> | 2026-06-12 16:36:10 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-12 16:36:10 +0900 |
| commit | 6689eb51b467d8e370f31495840d88661f978168 (patch) | |
| tree | d4234bfde98754dec48d2eff8594461780e609d4 /packages/surface-loaded-extensions/src | |
| parent | b3d270803f95db2467e20bb742aa42faf6867f91 (diff) | |
| download | dispatch-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.ts | 1 | ||||
| -rw-r--r-- | packages/surface-loaded-extensions/src/index.ts | 2 | ||||
| -rw-r--r-- | packages/surface-loaded-extensions/src/spec.test.ts | 84 | ||||
| -rw-r--r-- | packages/surface-loaded-extensions/src/spec.ts | 46 |
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], }; } |
