@unoverse-platform/studio 0.1.56 → 0.1.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ type: Box
2
+ visibleWhen: isEmpty
3
+ style:
4
+ direction: column
5
+ flex: 1
6
+ minHeight: "0"
7
+ overflow: hidden
8
+ justify: end
9
+ gap: "6"
10
+ padding: [ "8", "8", "4", "8" ]
11
+ children:
12
+ - type: Box
13
+ style:
14
+ direction: column
15
+ align: start
16
+ gap: "2"
17
+ maxWidth: reading
18
+ padding: [ "0", "8", "0", "0" ]
19
+ children:
20
+ - type: Text
21
+ style:
22
+ font: display
23
+ color: text.primary
24
+ lineHeight: none
25
+ align: start
26
+ value: How can I help?
27
+ - type: Text
28
+ style:
29
+ font: body.lg
30
+ color: text.secondary
31
+ align: start
32
+ value: Ask a question, or pick a place to start.
33
+ - type: Each
34
+ style:
35
+ direction: row
36
+ align: start
37
+ gap: "4"
38
+ width: full
39
+ padding: [ "2", "0", "2", "0" ]
40
+ template:
41
+ type: Button
42
+ action: sendMessage
43
+ style:
44
+ position: relative
45
+ flex: 1 1 0
46
+ minWidth: 0
47
+ height: "90"
48
+ padding: "0"
49
+ radius: 2xl
50
+ border: none
51
+ overflow: hidden
52
+ cursor: pointer
53
+ transition: all 200ms ease
54
+ shadow: lg
55
+ hover:
56
+ shadow: xl
57
+ transform: scale(1.02)
58
+ children:
59
+ - type: Image
60
+ bind:
61
+ src: image
62
+ alt: title
63
+ style:
64
+ position: absolute
65
+ inset: "0"
66
+ width: full
67
+ height: full
68
+ fit: cover
69
+ - type: Box
70
+ style:
71
+ position: absolute
72
+ inset: "0"
73
+ direction: column
74
+ align: start
75
+ justify: end
76
+ gap: "2"
77
+ padding: "4"
78
+ background: overlay.scrim
79
+ textAlign: start
80
+ children:
81
+ - type: Text
82
+ bind:
83
+ value: tag
84
+ style:
85
+ font: caption
86
+ color: white
87
+ lineHeight: tight
88
+ - type: Text
89
+ bind:
90
+ value: title
91
+ style:
92
+ font: title
93
+ color: white
94
+ lineHeight: tight
95
+ align: start
96
+ - type: Text
97
+ bind:
98
+ value: subtitle
99
+ style:
100
+ font: body.sm
101
+ color: white
102
+ lineHeight: snug
103
+ align: start
104
+ items:
105
+ - image: https://picsum.photos/seed/explore/840/1160
106
+ tag: Explore
107
+ title: What can you do?
108
+ subtitle: See what this assistant can help with.
109
+ text: What can you help me with?
110
+ - image: https://picsum.photos/seed/learn/840/1160
111
+ tag: Learn
112
+ title: Show me around
113
+ subtitle: A quick tour of how this works.
114
+ text: Give me a quick tour of what I can do here
115
+ - image: https://picsum.photos/seed/start/840/1160
116
+ tag: Start
117
+ title: Jump right in
118
+ subtitle: Describe what you need in your own words.
119
+ text: I would like some help getting started
@@ -43,7 +43,11 @@ export function checkProjectName(name) {
43
43
  const WELCOME = [
44
44
  "unoverse: '1.0'",
45
45
  "kind: component",
46
- "name: Welcome",
46
+ // name === filename (the item-key rule the publish lint enforces), and the closed
47
+ // style vocabulary has `font`, not `fontSize` — both caught by linting the scaffold
48
+ // output itself, which is the guard: the first component a developer ever sees must
49
+ // lint clean, or every rule the platform states is undermined by its own starter.
50
+ "name: welcome",
47
51
  "category: Content",
48
52
  "description: A first component. Edit this file and Studio updates as you save.",
49
53
  "root:",
@@ -52,7 +56,7 @@ const WELCOME = [
52
56
  " children:",
53
57
  " - type: Text",
54
58
  " value: Hello from your first component",
55
- " style: { fontSize: 'lg', color: 'text.primary' }",
59
+ " style: { font: 'body.lg', color: 'text.primary' }",
56
60
  "",
57
61
  ].join("\n");
58
62
 
@@ -81,6 +85,15 @@ export function createProject(home, name) {
81
85
  if (existsSync(sampleStyles) && readdirSync(join(root, "styles")).length === 0) {
82
86
  cpSync(sampleStyles, join(root, "styles"), { recursive: true });
83
87
  }
88
+
89
+ // A worked example TEMPLATE (a generic chat: welcome hero, suggestions, focus
90
+ // overlay — the bpp-chat-layout shape, neutralized to foundation tokens), so
91
+ // templates/ is never an empty folder and the first app is a rename away. Same
92
+ // rule as the styles: copied only into an empty folder, then the developer's.
93
+ const sampleTemplate = join(dirname(fileURLToPath(import.meta.url)), "sample-template/chat");
94
+ if (existsSync(sampleTemplate) && readdirSync(join(root, "templates")).length === 0) {
95
+ cpSync(sampleTemplate, join(root, "templates/chat"), { recursive: true });
96
+ }
84
97
  return root;
85
98
  }
86
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.56",
3
+ "version": "0.1.58",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -167,7 +167,10 @@ export function DesignSystemView({
167
167
  {isOverride("text", name) && <OverrideMark />}
168
168
  </div>
169
169
  <div className="min-w-0 flex-1">
170
- <div style={style as React.CSSProperties}>The quick brown fox</div>
170
+ {/* A typography token carries family/size/weight/line-height only — never a
171
+ colour — so the specimen would otherwise inherit Studio's ambient text
172
+ colour and wash out. Paint it in the THEME's ink, like the family name below. */}
173
+ <div style={{ ...(style as React.CSSProperties), color: ink }}>The quick brown fox</div>
171
174
  <div className="mt-0.5 text-[10px]" style={{ color: sub }}>
172
175
  {fams.length ? (
173
176
  <>
@@ -33,7 +33,7 @@ export function NativeAppHost({
33
33
  * (appEngine) and this relays it, so the host's badge can tell the truth.
34
34
  * `refused` carries the server's terminal refusal text (register_session refused
35
35
  * the session — public-entry gate, requires.role, stale token); undefined otherwise. */
36
- onStatus?: (s: { ready: boolean; connected: boolean; refused?: string }) => void;
36
+ onStatus?: (s: { ready: boolean; connected: boolean; refused?: string; failure?: { nodeIds: string[]; errors: Record<string, string> } }) => void;
37
37
  }) {
38
38
  const [html, setHtml] = useState<string | null>(null);
39
39
  const [error, setError] = useState<string | null>(null);
@@ -47,7 +47,7 @@ export function NativeAppHost({
47
47
  if (e.source !== frame.current?.contentWindow) return;
48
48
  if (e.data?.type === "unoverse:size" && e.data.width) setAppWidth(String(e.data.width));
49
49
  if (e.data?.type === "unoverse:status")
50
- onStatus?.({ ready: !!e.data.ready, connected: !!e.data.connected, refused: typeof e.data.refused === "string" ? e.data.refused : undefined });
50
+ onStatus?.({ ready: !!e.data.ready, connected: !!e.data.connected, refused: typeof e.data.refused === "string" ? e.data.refused : undefined, failure: e.data.failure ?? undefined });
51
51
  };
52
52
  window.addEventListener("message", onSize);
53
53
  return () => window.removeEventListener("message", onSize);
@@ -217,7 +217,7 @@ export function TemplatesView({
217
217
  // connection and posts `unoverse:status` up; the in-page `conn` below is never
218
218
  // opened in native mode, so a badge reading it said "Offline" while the app
219
219
  // streamed (observed live). Native status wins whenever the iframe has reported.
220
- const [nativeStatus, setNativeStatus] = useState<{ ready: boolean; connected: boolean; refused?: string } | null>(null);
220
+ const [nativeStatus, setNativeStatus] = useState<{ ready: boolean; connected: boolean; refused?: string; failure?: { nodeIds: string[]; errors: Record<string, string> } } | null>(null);
221
221
  useEffect(() => setNativeStatus(null), [selected, live]);
222
222
  // LIVE output inspector — the last submitted app output, projected through the
223
223
  // component's declared `outputs` (the same object the held tool call returns to
@@ -519,6 +519,11 @@ export function TemplatesView({
519
519
  nodeId: `${c.name}-${i}`,
520
520
  component: {
521
521
  type: c.name,
522
+ // The ADDRESS, exactly as a real server arrival carries it: the type alone
523
+ // cannot rebuild it (names are unique only within a tier — store.ts), so a
524
+ // mock seed without it resolved bare `unoverse://components/<name>` and the
525
+ // resolver rightly refused to guess between two orgs' clones.
526
+ componentUrl: `unoverse://components/${c.org ? `${c.org}/` : ""}${c.name}`,
522
527
  props: { ...defaultsOf(c), ...restState, defaultState: "inline" },
523
528
  },
524
529
  });
@@ -545,9 +550,12 @@ export function TemplatesView({
545
550
  Promise.all([
546
551
  // The header's org scope filters the list ("all" → every org's apps, each
547
552
  // tagged); the selected app's org still picks its own preview theme.
548
- // Components are universal — one catalog.
553
+ // Components are SCOPED like the templates: an org sees "universal + its own".
554
+ // Fetched unscoped, a cloned org's same-named component (design/lyceum cloned
555
+ // from design/bpp → two bare `journey-finder`s) sat beside the original and
556
+ // Mock seeding picked whichever listed first.
549
557
  authedFetch(`/dev/templates${org !== "all" ? `?org=${encodeURIComponent(org)}` : ""}`).then((r) => (r.ok ? r.json() : { templates: [] })),
550
- authedFetch("/dev/components").then((r) => (r.ok ? r.json() : { components: [] })),
558
+ authedFetch(`/dev/components${org !== "all" ? `?org=${encodeURIComponent(org)}` : ""}`).then((r) => (r.ok ? r.json() : { components: [] })),
551
559
  ])
552
560
  .then(([t, c]) => {
553
561
  const tl: Def[] = t.templates ?? [];
@@ -599,7 +607,14 @@ export function TemplatesView({
599
607
  const wantComponent = Boolean(layoutSel) || Boolean((activeState?.where as { field?: string } | undefined)?.field);
600
608
  let seededSet: Def[] = [];
601
609
  if (!wantEmpty) {
602
- const byName = (name: string) => components.find((c) => c.name.toLowerCase() === name.toLowerCase());
610
+ // ORG-AWARE: names are unique only within a tier, so a bare preview ref must
611
+ // resolve to the SELECTED APP's own org first, then the universal tier — never
612
+ // to another org's same-named clone (which the "all" scope still lists).
613
+ const appOrg = (selectedDef?.org ?? "").toLowerCase();
614
+ const byName = (name: string) => {
615
+ const hits = components.filter((c) => c.name.toLowerCase() === name.toLowerCase());
616
+ return hits.find((c) => (c.org ?? "").toLowerCase() === appOrg) ?? hits.find((c) => !c.org) ?? hits[0];
617
+ };
603
618
  const set = entry
604
619
  ? entry.map(byName).filter((c): c is Def => Boolean(c))
605
620
  : wantComponent
@@ -816,6 +831,15 @@ export function TemplatesView({
816
831
  {nativeStatus.refused}
817
832
  </div>
818
833
  )}
834
+ {nativeStatus?.failure && (
835
+ /* The last run's WORKFLOW_ERROR, verbatim per node — developer surface
836
+ only; a production app never renders this. */
837
+ <div className="mb-2 rounded border px-2 py-1.5 text-[11px] leading-snug" style={{ borderColor: "#ef4444", color: "#ef4444" }}>
838
+ {Object.entries(nativeStatus.failure.errors).map(([nodeId, err]) => (
839
+ <div key={nodeId}><span className="font-medium">{nodeId}</span>: {err}</div>
840
+ ))}
841
+ </div>
842
+ )}
819
843
  {/* Read-only: the app decides. Server is environment; Workflow + Trigger
820
844
  come from the selected app's manifest binding (no manual entry). */}
821
845
  {(
package/src/App.tsx CHANGED
@@ -18,11 +18,8 @@ import {
18
18
  PromptBlocksView,
19
19
  } from "@unoverse/studio";
20
20
  import { client, usePersistentState } from "./host";
21
- import { UniverseSession } from "./UniverseSession";
22
- import { ConnectUniverse } from "./ConnectUniverse";
23
- import { PublishDialog } from "./PublishDialog";
21
+ import { OfflineSession } from "./OfflineSession";
24
22
  import { NewProject } from "./NewProject";
25
- import { loadUniverses, type Universe } from "./universes";
26
23
  // Local-only screens. Nodes are AUTHORED and TESTED here and nowhere else: Canvas keeps
27
24
  // the node library (using nodes), the marketplace acquires them (a universe's job).
28
25
  import { NodesView } from "./NodesView";
@@ -40,37 +37,23 @@ const NAV: { key: View; label: string; group: string }[] = [
40
37
  ];
41
38
 
42
39
  /**
43
- * The shell, wrapped in the session for the selected universe.
40
+ * The shell, inside the permanently signed-out session.
44
41
  *
45
- * The provider must sit ABOVE every screen, because a screen deep in the tree
46
- * (TemplatesView) already calls `useAuth()`. With no universe selected nothing is
47
- * mounted and Studio behaves exactly as it did offline.
42
+ * The provider must sit ABOVE every screen, because a shared screen deep in the tree
43
+ * (TemplatesView) calls `useAuth()` unconditionally. Local Studio has no login and no
44
+ * publish button — identity lives in the CLI at publish time (`unoverse publish`;
45
+ * LOCAL_STUDIO.md §Publish is terminal-only) — so the session it provides is the
46
+ * signed-out one, always.
48
47
  */
49
48
  export function App() {
50
- // Which universe is selected, remembered across refreshes. The list itself lives in
51
- // localStorage (universes.ts); this is only the pointer into it.
52
- const [activeUrl, setActiveUrl] = usePersistentState<string | null>("studio:universe", null);
53
- const active = loadUniverses().find((u) => u.url === activeUrl) ?? null;
54
-
55
49
  return (
56
- <UniverseSession universe={active}>
57
- <Shell
58
- active={active}
59
- onSelectUniverse={(u: Universe | null) => setActiveUrl(u?.url ?? null)}
60
- />
61
- </UniverseSession>
50
+ <OfflineSession>
51
+ <Shell />
52
+ </OfflineSession>
62
53
  );
63
54
  }
64
55
 
65
- function Shell({
66
- active,
67
- onSelectUniverse,
68
- }: {
69
- active: Universe | null;
70
- onSelectUniverse: (u: Universe | null) => void;
71
- }) {
72
- const [connectOpen, setConnectOpen] = useState(false);
73
- const [publishOpen, setPublishOpen] = useState(false);
56
+ function Shell() {
74
57
  const [newProjectOpen, setNewProjectOpen] = useState(false);
75
58
  const [view, setView] = usePersistentState<View>("studio:view", "components");
76
59
  const [org, setOrg] = usePersistentState<string>("studio:org", "all");
@@ -168,43 +151,12 @@ function Shell({
168
151
  >
169
152
  {variant}
170
153
  </button>
171
- {/* The ONLY place Studio asks for anything. Unconnected reads as an invitation,
172
- not an error: working offline is a supported state, not a missing step. */}
173
- <button
174
- onClick={() => setConnectOpen(true)}
175
- className="rounded border border-gray-700 px-2 py-1 text-gray-300 hover:text-gray-100"
176
- title="Where your work publishes to"
177
- >
178
- {active ? active.label : "Not connected"}
179
- </button>
180
- {/* PUBLISHES THE SELECTED PROJECT. Disabled rather than hidden when there is
181
- nowhere to send or nothing selected: a missing button reads as a missing
182
- feature, a disabled one with a reason reads as a next step. */}
183
- <button
184
- onClick={() => setPublishOpen(true)}
185
- disabled={!active || org === "all"}
186
- title={
187
- !active
188
- ? "Connect a universe first"
189
- : org === "all"
190
- ? "Pick one project to publish"
191
- : `Publish ${org} to ${active.label}`
192
- }
193
- className={
194
- active && org !== "all"
195
- ? "rounded bg-emerald-500 px-3 py-1 font-medium text-emerald-950 hover:bg-emerald-400"
196
- : "rounded border border-gray-700 px-2 py-1 text-gray-500 disabled:cursor-not-allowed"
197
- }
198
- >
199
- Publish
200
- </button>
154
+ {/* NO PUBLISH BUTTON, DELIBERATELY (LOCAL_STUDIO.md §Publish is terminal-only).
155
+ Studio renders; shipping is `unoverse publish` in the terminal the developer
156
+ already has open, which signs in by itself when it needs to. */}
201
157
  </div>
202
158
  </header>
203
159
 
204
- {publishOpen && active && org !== "all" && (
205
- <PublishDialog project={org} universe={active} onClose={() => setPublishOpen(false)} />
206
- )}
207
-
208
160
  {/* Created, then SELECTED. Making a project and leaving the developer looking at the
209
161
  old one would be a step they have to discover. */}
210
162
  {newProjectOpen && (
@@ -218,14 +170,6 @@ function Shell({
218
170
  />
219
171
  )}
220
172
 
221
- {connectOpen && (
222
- <ConnectUniverse
223
- active={active}
224
- onSelect={onSelectUniverse}
225
- onClose={() => setConnectOpen(false)}
226
- />
227
- )}
228
-
229
173
  {/* The shared screens size themselves with `flex-1`, so the host must be a flex
230
174
  column. As a grid cell they would collapse to their content height. */}
231
175
  <main className="flex min-h-0 flex-col overflow-hidden">
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The permanently signed-out session local Studio hands the shared screens.
3
+ *
4
+ * Local Studio has NO auth chrome and NO publish path (LOCAL_STUDIO.md §Publish is
5
+ * terminal-only, 2026-08-21): authoring is offline, and identity exists only in the CLI
6
+ * at publish time (`unoverse login`). But the shared screens are also Canvas's, where a
7
+ * real session exists, so they call `useAuth()` unconditionally — and react-oidc-context
8
+ * warns loudly whenever the context VALUE is falsy. So the host supplies a real value
9
+ * meaning SIGNED OUT, permanently.
10
+ *
11
+ * The sign-in actions throw rather than no-op: reaching one from local Studio means a
12
+ * shared screen forgot which host it is in (the `hasAuth` capability flag is the check
13
+ * it should have made), and silence would hide that.
14
+ */
15
+ import type { ReactNode } from "react";
16
+ import { AuthContext, type AuthContextProps } from "react-oidc-context";
17
+
18
+ function offline(name: string) {
19
+ return async () => {
20
+ throw new Error(`${name} is not available in local Studio. Publishing signs in from the terminal: unoverse publish.`);
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Cast because `AuthContextProps` also carries the oidc-client-ts settings and event
26
+ * surface, none of which exists without a provider and none of which the screens touch.
27
+ */
28
+ const OFFLINE_SESSION = {
29
+ isAuthenticated: false,
30
+ isLoading: false,
31
+ user: undefined,
32
+ error: undefined,
33
+ activeNavigator: undefined,
34
+ signinRedirect: offline("signinRedirect"),
35
+ signinPopup: offline("signinPopup"),
36
+ signinSilent: offline("signinSilent"),
37
+ signoutRedirect: offline("signoutRedirect"),
38
+ signoutPopup: offline("signoutPopup"),
39
+ signoutSilent: offline("signoutSilent"),
40
+ removeUser: offline("removeUser"),
41
+ clearStaleState: offline("clearStaleState"),
42
+ querySessionStatus: offline("querySessionStatus"),
43
+ revokeTokens: offline("revokeTokens"),
44
+ startSilentRenew: () => {},
45
+ stopSilentRenew: () => {},
46
+ } as unknown as AuthContextProps;
47
+
48
+ export function OfflineSession({ children }: { children: ReactNode }) {
49
+ return <AuthContext.Provider value={OFFLINE_SESSION}>{children}</AuthContext.Provider>;
50
+ }
@@ -188,11 +188,24 @@ export function AppEngine(cfg: AppConfig) {
188
188
  // connection, so a host badge watching its own (never-opened) in-page connection
189
189
  // reads "offline" forever while this session streams (observed live). Report the
190
190
  // truth the same way size is reported; hosts that don't listen lose nothing.
191
+ //
192
+ // `failure` is the last run's WORKFLOW_ERROR detail (which node, what error) —
193
+ // DEVELOPER information. A production embed host doesn't listen and its users see
194
+ // nothing; Studio's preview listens and shows it, so a dead run stops being a
195
+ // silence only the server logs could explain.
196
+ const [failure, setFailure] = useState(store.getLastFailure());
197
+ useEffect(() => store.subscribe(() => setFailure(store.getLastFailure())), [store]);
191
198
  useEffect(() => {
192
199
  // `refused` carries the server's terminal refusal text (register_session isError) —
193
200
  // the one state a "Connecting…" badge must not sit on forever.
194
- postToEmbedHost({ type: "unoverse:status", ready: !!conn.isReady, connected: !!conn.isConnected, refused: conn.refusal ?? undefined });
195
- }, [conn.isReady, conn.isConnected, conn.refusal]);
201
+ postToEmbedHost({
202
+ type: "unoverse:status",
203
+ ready: !!conn.isReady,
204
+ connected: !!conn.isConnected,
205
+ refused: conn.refusal ?? undefined,
206
+ failure: failure ?? undefined,
207
+ });
208
+ }, [conn.isReady, conn.isConnected, conn.refusal, failure]);
196
209
 
197
210
  // VOICE (§2e-1): a `service: "voice"` app runs the native voice service — which now lives
198
211
  // in the shared renderer (StreamedUnoverseTemplate), identical to Studio. The host only
@@ -114,8 +114,19 @@ export function applyServerMessage(store: ComponentStore, msg: ServerMessage, se
114
114
  // born WITH its first component, so the streaming-and-empty window never exists.
115
115
  if (m.state === "WORKFLOW_STARTED" || m.state === "THINKING" || m.state === "RESPONDING" || m.state === "WAITING") {
116
116
  if (m.chatId) store.startResponse(m.chatId);
117
+ // A new run supersedes the previous run's failure detail.
118
+ if (m.state === "WORKFLOW_STARTED") store.setLastFailure(null);
117
119
  } else if (m.state === "WORKFLOW_COMPLETED" || m.state === "COMPLETE") {
118
120
  store.completeResponse(m.chatId);
121
+ } else if (m.state === "ERROR" || m.state === "WORKFLOW_ERROR") {
122
+ // SETTLE the turn exactly as completion does — a failed run must not leave the
123
+ // thinking state open forever (the eternal dots). What DIFFERS is invisible to
124
+ // a production user: the failure detail is stored for a developer host to
125
+ // read (Studio's preview); the SDK renders none of it.
126
+ store.completeResponse(m.chatId);
127
+ const failure = (m.metadata as { failure?: { nodeIds: string[]; errors: Record<string, string> } } | undefined)
128
+ ?.failure;
129
+ if (failure) store.setLastFailure(failure);
119
130
  }
120
131
  // Lifecycle machine — the global turn status (projected for visibleWhen).
121
132
  if (m.state === "WORKFLOW_STARTED" || m.state === "THINKING") store.setLifecycle("thinking");
@@ -274,6 +274,23 @@ export class ComponentStore {
274
274
  this.bump();
275
275
  }
276
276
 
277
+ // --- lastFailure: DEVELOPER detail from WORKFLOW_ERROR, stored and never rendered ---
278
+ //
279
+ // The SDK is a dumb renderer: a production app's experience does not change on a
280
+ // failed run (the turn settles exactly as on success). The failure detail is HELD
281
+ // here so a developer HOST that chooses to look (Studio's app preview) can show
282
+ // which node died and why. No SDK surface reads it.
283
+ private failure: { nodeIds: string[]; errors: Record<string, string> } | null = null;
284
+
285
+ /** The last run's failure detail, or null. Cleared when a new run starts. */
286
+ getLastFailure(): { nodeIds: string[]; errors: Record<string, string> } | null {
287
+ return this.failure;
288
+ }
289
+ setLastFailure(f: { nodeIds: string[]; errors: Record<string, string> } | null): void {
290
+ this.failure = f;
291
+ this.bump();
292
+ }
293
+
277
294
  /** Apply a streamed event (COMPONENT_INIT or COMPONENT_DATA). */
278
295
  apply(event: UnoverseEvent): void {
279
296
  const key = `${event.chatId}:${event.nodeId}`;