Skip to content
Merged
54 changes: 54 additions & 0 deletions apps/composer/app/catalog-export.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* The Catalog export (Preview's "export catalog"). The beta's experimental
* build adds a per-surface action manifest (docs/blocks/HOST-ACTIONS.md); the
* default build's export must stay byte-identical (docs/blocks/PHASE-2-RELEASE-PLAN.md,
* §12). The oracle below is the export as main wrote it before the action
* manifest (exportBundle in app/views/preview-view.tsx at 3edd589), kept
* verbatim minus the download, and the default export is held to it, byte for
* byte, over real project emits.
*/
import { describe, expect, it } from "vitest";
import { catalogBundle, catalogJson } from "./catalog-export";
import { REFERENCES } from "./demo-data";
import { browserEmit, contractSurfaces } from "./validation";

type Emit = { catalog?: Record<string, any>; surfaces?: Array<{ name: string; messages?: unknown[] }> };

/** main's exportBundle at 3edd589, minus the download: the bytes it wrote. */
function todaysBytes(emit: Emit, exportedAt: string): string {
const bundle = {
a2uiCatalog: emit.catalog,
surfaces: (emit.surfaces ?? [])
.filter((s) => s.messages)
.map((s) => ({ name: s.name, messages: s.messages })),
exportedAt,
};
return JSON.stringify(bundle, null, 2);
}

const AT = "2026-10-16T12:00:00.000Z";

describe("the default Catalog export is byte-identical to today's", () => {
for (const id of Object.keys(REFERENCES)) {
it(`${id}: the reference's own emit, every surface, exported exactly as before`, () => {
const { contract, profile } = REFERENCES[id]!;
const emit = browserEmit(contract as Record<string, unknown>, profile as Record<string, unknown>, contractSurfaces(contract as Record<string, unknown>)) as Emit;
expect(emit.surfaces?.length).toBeGreaterThan(0);
const bytes = catalogJson(catalogBundle(emit, AT));
expect(bytes).toBe(todaysBytes(emit, AT));
expect(Object.keys(JSON.parse(bytes))).toEqual(["a2uiCatalog", "surfaces", "exportedAt"]);
expect(bytes).not.toContain("actionManifest");
});
}

it("a refused surface is left out and a missing catalog is omitted, as before", () => {
const emit: Emit = { surfaces: [{ name: "kept", messages: [{ version: "v0.9" }] }, { name: "refused" }] };
expect(catalogJson(catalogBundle(emit, AT))).toBe(todaysBytes(emit, AT));
expect(JSON.parse(catalogJson(catalogBundle(emit, AT)))).toEqual({ surfaces: [{ name: "kept", messages: [{ version: "v0.9" }] }], exportedAt: AT });
});

it("an emit with no surfaces exports an empty list, as before", () => {
const emit: Emit = { catalog: { components: {} } };
expect(catalogJson(catalogBundle(emit, AT))).toBe(todaysBytes(emit, AT));
});
});
53 changes: 53 additions & 0 deletions apps/composer/app/catalog-export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* The Catalog export (Preview's "export catalog"): the emitted A2UI catalog,
* the vocabulary an A2UI/AG-UI client consumes, plus every emitted surface's
* messages (worked AG-UI payloads), as one JSON download. A pure browser
* download, in demo mode and agent mode alike, with no filesystem round
* trip, so a hosted user can take the catalog away and use it.
*
* The beta's experimental build adds a per-surface action manifest to the
* bundle (docs/blocks/HOST-ACTIONS.md). Nothing here knows of it, and the
* default build's export stays byte-identical to the one before it
* (catalog-export.test.ts).
*/

/** What the export reads from an emit. */
export interface CatalogEmit {
catalog?: unknown;
surfaces?: ReadonlyArray<{ name: string; messages?: unknown[] }>;
}

export interface CatalogBundle {
a2uiCatalog: unknown;
surfaces: Array<{ name: string; messages: unknown[] }>;
exportedAt: string;
}

/** The catalog and every surface that emitted; a refused surface has no messages to take away. */
export function catalogBundle(emit: CatalogEmit, exportedAt: string): CatalogBundle {
return {
a2uiCatalog: emit.catalog,
surfaces: (emit.surfaces ?? [])
.filter((s) => s.messages)
.map((s) => ({ name: s.name, messages: s.messages as unknown[] })),
exportedAt,
};
}

/** The bytes the download holds. */
export function catalogJson(bundle: object): string {
return JSON.stringify(bundle, null, 2);
}

/** Download a bundle as `<name>-catalog.json`. */
export function downloadCatalog(bundle: object, name: string): void {
const blob = new Blob([catalogJson(bundle)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${name.replace(/[^a-z0-9-]+/gi, "-").toLowerCase() || "a2ui"}-catalog.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
21 changes: 17 additions & 4 deletions apps/composer/app/state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,8 @@ export interface ComposerState {
importProject: (text: string) => { ok: true; name: string } | { ok: false; error: string };
/** Export a project to a downloadable, portable file. */
exportProject: (id: string) => void;
/** Interface blocks (experimental): a project's block provenance, which its project export and the beta's Catalog export carry. Always empty in default builds. */
blockProvenanceFor: (projectId: string) => Record<string, unknown>;
/** Open a packaged reference as a read-only EXAMPLE workspace (ephemeral —
* never in "Your projects", pristine on every open, session edits only). */
openExample: (referenceId: string) => void;
Expand Down Expand Up @@ -640,6 +642,17 @@ export function ComposerProvider({ children }: { children: ReactNode }) {
[openProject],
);

/**
* Interface blocks (experimental): a project's block provenance, for its
* project export and the beta's Catalog export (docs/blocks/HOST-ACTIONS.md).
* An Example session keeps its own in memory. Default builds read none.
*/
const blockProvenanceFor = useCallback(
(id: string): Record<string, unknown> =>
process.env.NEXT_PUBLIC_COMPOSER_EXPERIMENTAL === "1" ? (exampleProject && id === exampleProject.id ? sessionProvenance.current : loadBlockProvenance(id)) : {},
[exampleProject],
);

/**
* Export a project to a downloadable file — its identity plus its governed
* vocabulary, nothing machine-specific. The OPEN project exports exactly
Expand Down Expand Up @@ -684,13 +697,12 @@ export function ComposerProvider({ children }: { children: ReactNode }) {
const projectFlows = id === activeProjectId ? flows : loadFlowsStore(id);
// Interface blocks (experimental): provenance is project data and rides
// along, additive like flows. Default builds export none.
const blockProvenance =
process.env.NEXT_PUBLIC_COMPOSER_EXPERIMENTAL === "1" ? (exampleProject && id === exampleProject.id ? sessionProvenance.current : loadBlockProvenance(id)) : {};
const blockProvenance = blockProvenanceFor(id);
downloadProjectExport(
buildProjectExport({ name: p.name, description: p.description, vocab, exportedAt: new Date().toISOString(), flows: projectFlows, blockProvenance }),
);
},
[activeProjectId, contract, profile, manifest, flows, exampleProject],
[activeProjectId, contract, profile, manifest, flows, blockProvenanceFor],
);

/** Return to the hub without loading a project (ends an example session). */
Expand Down Expand Up @@ -1668,6 +1680,7 @@ export function ComposerProvider({ children }: { children: ReactNode }) {
deleteProject,
importProject,
exportProject,
blockProvenanceFor,
openExample,
isExample: exampleProject !== null,
duplicateExample,
Expand Down Expand Up @@ -1703,7 +1716,7 @@ export function ComposerProvider({ children }: { children: ReactNode }) {
experimental,
setExperimental,
}),
[mode, agentUp, projectPath, manifest, contract, profile, ledger, rediscovery, emit, validate, busy, notice, selected, connect, referenceId, loadReference, referenceExampleIds, projects, activeProject, newProject, openProject, closeProject, renameProject, duplicateProject, deleteProject, importProject, exportProject, openExample, exampleProject, duplicateExample, discover, rediscover, saveContract, saveProfile, resolveDeletion, resolveConflict, clearTombstone, acceptFreshFact, runEmit, runValidate, flows, saveFlows, buildTurns, buildBusy, buildModels, selectableModels, activeModel, setActiveModel, providerConfig, storedProviders, openaiKey, configureLocalProvider, readiness, runBuild, acceptBuildTurn, clearBuildThread, flowComposition, experimental, setExperimental],
[mode, agentUp, projectPath, manifest, contract, profile, ledger, rediscovery, emit, validate, busy, notice, selected, connect, referenceId, loadReference, referenceExampleIds, projects, activeProject, newProject, openProject, closeProject, renameProject, duplicateProject, deleteProject, importProject, exportProject, blockProvenanceFor, openExample, exampleProject, duplicateExample, discover, rediscover, saveContract, saveProfile, resolveDeletion, resolveConflict, clearTombstone, acceptFreshFact, runEmit, runValidate, flows, saveFlows, buildTurns, buildBusy, buildModels, selectableModels, activeModel, setActiveModel, providerConfig, storedProviders, openaiKey, configureLocalProvider, readiness, runBuild, acceptBuildTurn, clearBuildThread, flowComposition, experimental, setExperimental],
);

return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
Expand Down
52 changes: 24 additions & 28 deletions apps/composer/app/views/preview-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { A2uiCanvas, type A2uiClientAction, type Registry } from "@dspack-studio/a2ui-ingest";
import { catalogBundle, downloadCatalog } from "../catalog-export";
import { useComposer } from "../state";
import { ViewHeader } from "../ui";
import { isPendingStep, mintStepId, missingSurfaceMessage, nextFlowId, pendingStepMessage, type Flow, type FlowStep } from "../flows";
Expand All @@ -33,32 +34,6 @@ import {

type RegistryId = "wireframe" | PreviewRegistryId;

/**
* Export the emitted artifacts as a downloadable JSON bundle: the A2UI
* catalog (the vocabulary an A2UI/AG-UI client consumes) plus every emitted
* surface's messages (worked AG-UI payloads). Pure browser download — works
* in demo mode and agent mode alike, with no filesystem round-trip, so a
* hosted user can actually take the catalog away and use it.
*/
function exportBundle(emit: NonNullable<ReturnType<typeof useComposer>["emit"]>, name: string): void {
const bundle = {
a2uiCatalog: emit.catalog,
surfaces: (emit.surfaces ?? [])
.filter((s) => s.messages)
.map((s) => ({ name: s.name, messages: s.messages })),
exportedAt: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${name.replace(/[^a-z0-9-]+/gi, "-").toLowerCase() || "a2ui"}-catalog.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}

const field = {
fontFamily: "var(--mono)",
fontSize: 12,
Expand Down Expand Up @@ -102,7 +77,7 @@ const draftFrom = (flow: Flow): FlowDraft => ({
});

export function PreviewView({ focus }: { focus?: "flows" } = {}) {
const { contract, emit, manifest, referenceExampleIds, isExample, mode, activeProject, projectPath, flows, saveFlows } = useComposer();
const { contract, emit, manifest, referenceExampleIds, isExample, mode, activeProject, projectPath, flows, saveFlows, blockProvenanceFor } = useComposer();
// null = "no explicit choice yet" — the project's own design system draws
// it. An explicit pick survives until the project changes (B5).
const [registryId, setRegistryId] = useState<RegistryId | null>(null);
Expand All @@ -117,6 +92,27 @@ export function PreviewView({ focus }: { focus?: "flows" } = {}) {

const catalog = emit?.catalog;
const surfaces = (emit?.surfaces ?? []).filter((s) => s.messages);

/**
* The Catalog export (catalog-export.ts). The beta's experimental build adds
* its per-surface action manifest (docs/blocks/HOST-ACTIONS.md); if that
* cannot be built, today's export goes out instead, which binds nothing.
*/
const exportCatalog = (current: NonNullable<typeof emit>, name: string) => {
const bundle = catalogBundle(current, new Date().toISOString());
if (process.env.NEXT_PUBLIC_COMPOSER_EXPERIMENTAL === "1") {
const provenance = activeProject ? blockProvenanceFor(activeProject.id) : {};
const examples = (contract?.examples as Array<{ id?: unknown; surface?: unknown }> | undefined) ?? [];
void import("../../experimental/blocks-runtime")
.then((runtime) => runtime.withActionManifest(bundle, { surfaces: current.surfaces ?? [], examples, provenance }))
.then(
(withManifest) => downloadCatalog(withManifest, name),
() => downloadCatalog(bundle, name),
);
return;
}
downloadCatalog(bundle, name);
};
// A surface a person reads by its TITLE — the goal that produced it, or the
// name someone authored — with `ex.chat-1` kept beside it for audit (B7).
const entriesById = useMemo(() => surfaceEntriesById(contract?.examples), [contract]);
Expand Down Expand Up @@ -394,7 +390,7 @@ export function PreviewView({ focus }: { focus?: "flows" } = {}) {
<button
className="st-btn st-btn--dashed"
style={{ marginLeft: "auto" }}
onClick={() => emit && exportBundle(emit, manifest?.name ?? "a2ui")}
onClick={() => emit && exportCatalog(emit, manifest?.name ?? "a2ui")}
data-testid="export-catalog"
title="Download the emitted A2UI catalog + surface messages as JSON"
>
Expand Down
105 changes: 103 additions & 2 deletions apps/composer/experimental/blocks-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,20 @@ import { createCallRecorder, foldBuildEvents, planDeterministic } from "@dspack-
import { REFERENCES } from "../app/demo-data";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import type { BlocksBetaSwitch } from "@dspack-studio/composer-core/blocks";
import { blockBinding, blockContext, blockRunFor, chooseBlock, planGoalWithBlocks, readBlocksBeta, runHostedBlockBuild, type BlockContext } from "./blocks-runtime";
import { expandBlock, expansionDigest, type BlocksBetaSwitch } from "@dspack-studio/composer-core/blocks";
import { catalogBundle } from "../app/catalog-export";
import { browserEmit, contractSurfaces } from "../app/validation";
import {
blockBinding,
blockContext,
blockRunFor,
chooseBlock,
planGoalWithBlocks,
readBlocksBeta,
runHostedBlockBuild,
withActionManifest,
type BlockContext,
} from "./blocks-runtime";

type Json = Record<string, any>;

Expand Down Expand Up @@ -414,3 +426,92 @@ describe("named calls only (Phase 2 plan, §5)", () => {
expect(source).not.toMatch(/positional/i);
});
});

describe("the beta's Catalog export carries the action manifest (docs/blocks/HOST-ACTIONS.md)", () => {
const AT = "2026-10-16T12:00:00.000Z";
const ACCEPTED = "ex.chat-1";

/** An Astryx project that accepted the auth-form example as a block surface, and its provenance entry. */
async function acceptedAuthForm() {
const base = (await blockBinding(reference))!;
const block = base.sidecar.blocks.find((b) => b.id === "auth-form")!;
const example = block.examples[0]!;
const expanded = expandBlock(block, example.call, { reference: base.sidecar.reference, intent: example.intent });
if (!expanded.ok) throw new Error(expanded.message);
const surface = expanded.surface as unknown as Json;
const live = { ...(astryx.contract as Json), examples: [...((astryx.contract as Json).examples as Json[]), { id: ACCEPTED, name: "Sign in", intent: example.intent, prompt: "a sign-in form", surface }] };
const entry = {
v: 1,
reference: base.sidecar.reference,
baseSha256: base.sidecar.baseSha256,
block: "auth-form",
version: 1,
intent: example.intent,
blockSchemaSha256: "a".repeat(64),
encoding: "named",
call: example.call,
expansionDigest: await expansionDigest(surface),
};
return { live, entry, surface };
}

/** Exactly what Preview exports: the app's own emit, today's bundle, then the manifest. */
async function exported(live: Json, provenance: Record<string, unknown>) {
const emit = browserEmit(live, astryx.profile as Json, contractSurfaces(live));
const bundle = catalogBundle(emit, AT);
const out = await withActionManifest(bundle, { surfaces: emit.surfaces ?? [], examples: (live.examples as Json[]) ?? [], provenance });
return { bundle, out: out as Json };
}

it("adds the manifest before exportedAt and changes nothing else; the accepted block surface binds, the reference's own examples bind nothing", async () => {
const { live, entry } = await acceptedAuthForm();
const { bundle, out } = await exported(live, { [ACCEPTED]: entry });
expect(Object.keys(out)).toEqual(["a2uiCatalog", "surfaces", "actionManifest", "exportedAt"]);
expect(out.a2uiCatalog).toEqual(bundle.a2uiCatalog);
expect(out.surfaces).toEqual(bundle.surfaces);
expect(out.exportedAt).toBe(AT);

const manifest = out.actionManifest as Json;
expect(manifest.v).toBe(1);
expect(manifest.surfaces.map((s: Json) => s.surface)).toEqual(bundle.surfaces.map((s) => s.name));
const accepted = manifest.surfaces.find((s: Json) => s.surface === ACCEPTED);
expect(accepted.block).toMatchObject({ id: "auth-form", version: 1, intent: "data-collection", streaming: "disable-actions-until-complete", expansionDigest: entry.expansionDigest });
expect(accepted.actions).toEqual([
{ name: "auth_provider_github", declared: true },
{ name: "auth_submit", declared: true },
]);
for (const other of manifest.surfaces.filter((s: Json) => s.surface !== ACCEPTED)) {
expect(other.block).toBeNull();
expect(other.actions.every((a: Json) => a.declared === false)).toBe(true);
}
});

it("an accepted surface edited afterwards: the manifest is recomputed from its messages, with no binding and the new action undeclared", async () => {
const { live, entry, surface } = await acceptedAuthForm();
const edited = structuredClone(surface);
edited.root.children.push({ component: "button", id: "auth-delete-account", props: { label: "Delete account", variant: "secondary" } });
const examples = (live.examples as Json[]).map((e) => (e.id === ACCEPTED ? { ...e, surface: edited } : e));
const { out } = await exported({ ...live, examples }, { [ACCEPTED]: entry });
const accepted = (out.actionManifest as Json).surfaces.find((s: Json) => s.surface === ACCEPTED);
expect(accepted.block).toBeNull();
expect(accepted.actions).toContainEqual({ name: "auth_delete_account", declared: false });
expect(accepted.actions.every((a: Json) => a.declared === false)).toBe(true);
});

it("forged provenance on a reference example grants nothing", async () => {
const { live, entry } = await acceptedAuthForm();
const target = ((astryx.contract as Json).examples as Json[]).find((e) => e.surface)!.id as string;
const { out } = await exported(live, { [target]: { ...entry, expansionDigest: "f".repeat(64) }, [ACCEPTED]: entry });
const forged = (out.actionManifest as Json).surfaces.find((s: Json) => s.surface === target);
expect(forged.block).toBeNull();
expect(forged.actions.every((a: Json) => a.declared === false)).toBe(true);
});

it("no provenance at all: every surface is listed, and none binds", async () => {
const { live } = await acceptedAuthForm();
const { out } = await exported(live, {});
const surfaces = (out.actionManifest as Json).surfaces as Json[];
expect(surfaces.length).toBeGreaterThan(1);
for (const s of surfaces) expect(s.block).toBeNull();
});
});
Loading
Loading