@unoverse-platform/studio 0.1.90 → 0.1.92
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.
- package/local/plugin.ts +21 -65
- package/package.json +13 -3
- package/vendor/base/src/agent-mcp/discovery.ts +203 -0
- package/vendor/base/src/agent-mcp/handoff.ts +56 -0
- package/vendor/base/src/agent-mcp/index.ts +44 -0
- package/vendor/base/src/agent-mcp/invoke.ts +153 -0
- package/vendor/base/src/agent-mcp/lean.ts +114 -0
- package/vendor/base/src/agent-mcp/memoryContext.ts +104 -0
- package/vendor/base/src/agent-mcp/memoryIngest.ts +88 -0
- package/vendor/base/src/agent-mcp/rowComponents.ts +234 -0
- package/vendor/base/src/agent-mcp/tools.ts +134 -0
- package/vendor/base/src/agent-mcp/types.ts +138 -0
- package/vendor/base/src/auth/auth.ts +307 -0
- package/vendor/base/src/auth/authConfig.ts +73 -0
- package/vendor/base/src/boot.ts +245 -0
- package/vendor/base/src/definitions/definitions.ts +18 -0
- package/vendor/base/src/definitions/dsPackage.ts +45 -0
- package/vendor/base/src/definitions/fsCache.ts +155 -0
- package/vendor/base/src/definitions/index.ts +19 -0
- package/vendor/base/src/definitions/inputs.ts +30 -0
- package/vendor/base/src/definitions/load/compile/component.ts +80 -0
- package/vendor/base/src/definitions/load/compile/index.ts +12 -0
- package/vendor/base/src/definitions/load/compile/sections.ts +132 -0
- package/vendor/base/src/definitions/load/compile/template.ts +153 -0
- package/vendor/base/src/definitions/load/expand/includes.ts +27 -0
- package/vendor/base/src/definitions/load/expand/index.ts +17 -0
- package/vendor/base/src/definitions/load/expand/inherit.ts +169 -0
- package/vendor/base/src/definitions/load/expand/referenced.ts +164 -0
- package/vendor/base/src/definitions/load/expand/refs.ts +106 -0
- package/vendor/base/src/definitions/load/expand/walk.ts +91 -0
- package/vendor/base/src/definitions/load/load.ts +487 -0
- package/vendor/base/src/definitions/model.ts +238 -0
- package/vendor/base/src/definitions/registry/catalog.ts +659 -0
- package/vendor/base/src/definitions/registry/homes.ts +252 -0
- package/vendor/base/src/definitions/theme.ts +383 -0
- package/vendor/base/src/executor.ts +284 -0
- package/vendor/base/src/http/uwsFetchAdapter.ts +397 -0
- package/vendor/base/src/items/baseVersion.ts +96 -0
- package/vendor/base/src/items/collect.ts +246 -0
- package/vendor/base/src/items/fingerprint.ts +34 -0
- package/vendor/base/src/items/publish.ts +212 -0
- package/vendor/base/src/lint/context.mjs +56 -0
- package/vendor/base/src/lint/design/defs.mjs +46 -0
- package/vendor/base/src/lint/design/file.mjs +737 -0
- package/vendor/base/src/lint/design/index.d.mts +30 -0
- package/vendor/base/src/lint/design/index.mjs +996 -0
- package/vendor/base/src/lint/design/tokens.mjs +128 -0
- package/vendor/base/src/lint/design/vocabulary.mjs +181 -0
- package/vendor/base/src/lint/design/walk.mjs +444 -0
- package/vendor/base/src/lint/index.mjs +28 -0
- package/vendor/base/src/lint/nodes/blocks.mjs +63 -0
- package/vendor/base/src/lint/nodes/index.d.mts +28 -0
- package/vendor/base/src/lint/nodes/index.mjs +82 -0
- package/vendor/base/src/lint/nodes/load.mjs +126 -0
- package/vendor/base/src/lint/nodes/node.mjs +764 -0
- package/vendor/base/src/lint/nodes/package.mjs +112 -0
- package/vendor/base/src/lint/nodes/schema.mjs +75 -0
- package/vendor/base/src/logger.ts +33 -0
- package/vendor/base/src/manifests/compose.ts +539 -0
- package/vendor/base/src/manifests/executor/callback.ts +107 -0
- package/vendor/base/src/manifests/executor/context.ts +110 -0
- package/vendor/base/src/manifests/executor/index.ts +47 -0
- package/vendor/base/src/manifests/executor/lookup.ts +21 -0
- package/vendor/base/src/manifests/executor/memory.ts +62 -0
- package/vendor/base/src/manifests/executor/promise.ts +71 -0
- package/vendor/base/src/manifests/executor/refire.ts +34 -0
- package/vendor/base/src/manifests/executor/send.ts +65 -0
- package/vendor/base/src/manifests/executor/session.ts +46 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
- package/vendor/base/src/manifests/index.ts +280 -0
- package/vendor/base/src/manifests/integrity.ts +93 -0
- package/vendor/base/src/manifests/rows.ts +33 -0
- package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
- package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
- package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
- package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
- package/vendor/base/src/manifests/runtime/channels/service.ts +138 -0
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
- package/vendor/base/src/manifests/runtime/context.ts +156 -0
- package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
- package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
- package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
- package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
- package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
- package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
- package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
- package/vendor/base/src/manifests/runtime/events.ts +223 -0
- package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
- package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
- package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
- package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
- package/vendor/base/src/manifests/runtime/index.ts +57 -0
- package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
- package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
- package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
- package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
- package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
- package/vendor/base/src/manifests/runtime/state.ts +171 -0
- package/vendor/base/src/manifests/runtime/templating.ts +110 -0
- package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +550 -0
- package/vendor/base/src/manifests/runtime/usage.ts +87 -0
- package/vendor/base/src/manifests/source.ts +196 -0
- package/vendor/base/src/manifests/watch.ts +67 -0
- package/vendor/base/src/markdown/document.ts +141 -0
- package/vendor/base/src/paths.ts +180 -0
- package/vendor/base/src/platform/audioManager.ts +100 -0
- package/vendor/base/src/platform/clientTransport.ts +65 -0
- package/vendor/base/src/platform/executionContext.ts +157 -0
- package/vendor/base/src/platform/index.ts +10 -0
- package/vendor/base/src/platform/pluginAPI.ts +146 -0
- package/vendor/base/src/platform/redis.ts +54 -0
- package/vendor/base/src/platform/serviceCalls.ts +222 -0
- package/vendor/base/src/pluginBase.ts +248 -0
- package/vendor/base/src/plugins/README.md +57 -0
- package/vendor/base/src/plugins/discovery.ts +87 -0
- package/vendor/base/src/plugins/index.ts +16 -0
- package/vendor/base/src/plugins/loader.ts +179 -0
- package/vendor/base/src/plugins/redis.ts +36 -0
- package/vendor/base/src/plugins/state.ts +53 -0
- package/vendor/base/src/registry.ts +95 -0
- package/vendor/base/src/security/permissions.ts +93 -0
- package/vendor/base/src/security/rules.ts +73 -0
- package/vendor/base/src/template/SafeExpression.ts +556 -0
- package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
- package/vendor/base/src/template/index.ts +22 -0
- package/vendor/base/src/types.ts +50 -0
- package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
- package/vendor/base/src/workflow/PlatformMemoryCore.ts +449 -0
- package/vendor/base/src/workflow/routing.ts +122 -0
- package/vendor/sdk/lib/core/store.ts +26 -6
- package/local/mcp.ts +0 -265
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the tool bridge from the platform's own harness.
|
|
3
|
+
*
|
|
4
|
+
* Every MCP judgement lives in ../../agent-mcp and is reached from here: what a
|
|
5
|
+
* tool is, what a discovery result unlocks, when a turn ends, how much of a result the
|
|
6
|
+
* model needs. The loop only owns the wire format. That split is what stops each agent
|
|
7
|
+
* family re-making these decisions and drifting apart, which is how GLM and Grok ended
|
|
8
|
+
* up unable to launch spatial apps.
|
|
9
|
+
*
|
|
10
|
+
* Returns undefined when nothing granted tools, and the node then runs as a single request.
|
|
11
|
+
*/
|
|
12
|
+
import type { ToolBridge } from "../runtime/index.js";
|
|
13
|
+
|
|
14
|
+
export async function toolBridgeFor(executionContext: any): Promise<ToolBridge | undefined> {
|
|
15
|
+
const api = executionContext?.api;
|
|
16
|
+
if (!api?.callService) return undefined;
|
|
17
|
+
|
|
18
|
+
const harness: any = await import("../../agent-mcp/index.js");
|
|
19
|
+
|
|
20
|
+
let schema: any;
|
|
21
|
+
try {
|
|
22
|
+
schema = await api.callService("getSchema", {}, executionContext);
|
|
23
|
+
} catch {
|
|
24
|
+
return undefined; // no MCP provider wired
|
|
25
|
+
}
|
|
26
|
+
if (!schema?.methods) return undefined;
|
|
27
|
+
|
|
28
|
+
const core = new Set<string>(Object.keys(schema.methods));
|
|
29
|
+
|
|
30
|
+
// Identity for a HELD app call: it renders on the caller's live session and elicits
|
|
31
|
+
// from that human, so it needs the conversation, the turn, and the caller's token.
|
|
32
|
+
// Using auth HERE is correct and different from exposing it to a manifest: this is the
|
|
33
|
+
// platform calling its own internal /mcp on the user's behalf, not data deciding where
|
|
34
|
+
// a secret goes.
|
|
35
|
+
const pub = executionContext?.publishingContext;
|
|
36
|
+
const identity = {
|
|
37
|
+
conversationId: pub?.conversationId,
|
|
38
|
+
userId: pub?.userId,
|
|
39
|
+
chatId: pub?.chatId,
|
|
40
|
+
accessToken: executionContext?.auth?.accessToken,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Discovered PATH-B component apps, by tool name.
|
|
45
|
+
*
|
|
46
|
+
* These are NOT ordinary service calls. UNOVERSE_MCP_APP_PROTOCOL §3.3/§4b: a
|
|
47
|
+
* bindingless component app is invoked through the held `/mcp` call so the server
|
|
48
|
+
* renders it, elicits from the user, and returns their answers AND the
|
|
49
|
+
* post-elicitation guidance to the model. Routing one through callService instead
|
|
50
|
+
* would silently drop elicitation — the app would render and the model would never
|
|
51
|
+
* learn what the human said.
|
|
52
|
+
*/
|
|
53
|
+
const appInvokers = new Map<string, (input: unknown) => Promise<unknown>>();
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Which apps this run has already unlocked, by component name.
|
|
57
|
+
*
|
|
58
|
+
* The harness keys the shell-open off this set: an app opens its page ONCE per run, and a
|
|
59
|
+
* later search that surfaces the same row again must not fire a second skeleton over a page
|
|
60
|
+
* the guest is already reading. Owned per bridge, so two agents in one workflow do not
|
|
61
|
+
* share each other's unlocks.
|
|
62
|
+
*/
|
|
63
|
+
const discoveredApps = new Set<string>();
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* THE GUEST'S OWN WORDS, from the search that is about to unlock a page.
|
|
67
|
+
*
|
|
68
|
+
* A page's `query:` places ask half a sentence each (§9e rule 4) — the half true of every
|
|
69
|
+
* guest — and the platform joins the half that is different for each. This is where that
|
|
70
|
+
* half is picked up: the model wrote the guest's ask into its search args a moment ago, so
|
|
71
|
+
* nothing has to be re-derived or, worse, re-invented.
|
|
72
|
+
*
|
|
73
|
+
* Captured on the CALL and read on the ABSORB, because `absorb` receives only the result.
|
|
74
|
+
* Whoever knows the situation supplies it (§9d) — the shell-open passes it on rather than
|
|
75
|
+
* the server guessing what the conversation was about.
|
|
76
|
+
*/
|
|
77
|
+
let lastAsk = "";
|
|
78
|
+
|
|
79
|
+
const stringify = (r: unknown): string => (typeof r === "string" ? r : JSON.stringify(r));
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* PAGES ALREADY OPEN IN THIS CONVERSATION (the latch's other half, 2026-09-02).
|
|
83
|
+
*
|
|
84
|
+
* A follow-up send addressed at an open page starts a FRESH agent, and the page's tool
|
|
85
|
+
* was minted in an earlier run and died with it — so the agent could only chat at the
|
|
86
|
+
* page (observed live: three consecutive turns of clarifying questions at a latched
|
|
87
|
+
* page it had no tool to edit). At bridge build the open pages are read from the
|
|
88
|
+
* server's open-apps resource and their tools minted exactly like an unlock mint,
|
|
89
|
+
* minus the shell-open — the page is already on screen. The same resource read RE-ARMS
|
|
90
|
+
* read-first server-side, so this new agent's first authored call is answered with the
|
|
91
|
+
* current page rather than rendered blind. Fail-soft: any error means nothing
|
|
92
|
+
* pre-minted, which is exactly the old behaviour.
|
|
93
|
+
*/
|
|
94
|
+
const preMinted: any[] = [];
|
|
95
|
+
if (identity.conversationId) {
|
|
96
|
+
try {
|
|
97
|
+
const { client, close } = await harness.connectPlatformMcp({ accessToken: identity.accessToken });
|
|
98
|
+
try {
|
|
99
|
+
const res = (await client.readResource({ uri: `unoverse://conversations/${identity.conversationId}/open-apps` })) as {
|
|
100
|
+
contents?: Array<{ text?: string }>;
|
|
101
|
+
};
|
|
102
|
+
const open = JSON.parse(res?.contents?.[0]?.text ?? "[]") as Array<{ methodName: string; appUri: string; title?: string; description?: string }>;
|
|
103
|
+
for (const o of open) {
|
|
104
|
+
if (!o?.methodName || !o?.appUri) continue;
|
|
105
|
+
const mcp = {
|
|
106
|
+
id: o.appUri,
|
|
107
|
+
title: o.title ?? "",
|
|
108
|
+
description: `${o.description || o.title || o.methodName} ${harness.APP_ETIQUETTE}`,
|
|
109
|
+
workflowId: "",
|
|
110
|
+
nodeId: "",
|
|
111
|
+
methodName: o.methodName,
|
|
112
|
+
component: harness.componentTypeFromAppUri(o.appUri),
|
|
113
|
+
appUri: o.appUri,
|
|
114
|
+
schema: { methods: {} },
|
|
115
|
+
};
|
|
116
|
+
const def = await harness.resolveToolDefNative(mcp, { accessToken: identity.accessToken });
|
|
117
|
+
appInvokers.set(def.name, harness.componentAppInvoker(mcp, identity));
|
|
118
|
+
discoveredApps.add(mcp.component);
|
|
119
|
+
preMinted.push({
|
|
120
|
+
...def,
|
|
121
|
+
description:
|
|
122
|
+
`${def.description}\n\nTHIS PAGE IS ALREADY OPEN in this conversation — the user is looking at it now. ` +
|
|
123
|
+
`To act on their message, call this tool with the parts to change; your first call is answered with the current page so you read before editing.`,
|
|
124
|
+
});
|
|
125
|
+
console.log(`[manifests] open page re-minted for this turn: ${def.name}`);
|
|
126
|
+
}
|
|
127
|
+
} finally {
|
|
128
|
+
await close();
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
/* no open pages, or a server without the resource — nothing pre-minted */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
async discover() {
|
|
137
|
+
return [
|
|
138
|
+
...Object.entries<any>(schema.methods).map(([name, m]) => ({
|
|
139
|
+
name,
|
|
140
|
+
description: m.description || `Execute ${name}`,
|
|
141
|
+
parameters: m.input || { type: "object", properties: {} },
|
|
142
|
+
})),
|
|
143
|
+
...preMinted,
|
|
144
|
+
];
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
async call(name, args) {
|
|
148
|
+
const app = appInvokers.get(name);
|
|
149
|
+
if (app) return stringify(await app(args));
|
|
150
|
+
if (harness.DISCOVERY_TOOL_NAMES.includes(name)) {
|
|
151
|
+
const a = (args ?? {}) as Record<string, unknown>;
|
|
152
|
+
const q = typeof a.query === "string" ? a.query : Array.isArray(a.queries) ? String(a.queries[0] ?? "") : "";
|
|
153
|
+
if (q.trim()) lastAsk = q.trim();
|
|
154
|
+
}
|
|
155
|
+
// THE ARGUMENTS GO OUT AS THE MODEL WROTE THEM. A wire-level rewrite used to strip
|
|
156
|
+
// queries the conversation had already searched; it was removed 2026-08-16 because it
|
|
157
|
+
// silently competed with the tool descriptions for the same decision, and the
|
|
158
|
+
// descriptions are where it belongs. What a turn should search, and whether that turn
|
|
159
|
+
// wants app cards, is the MCP prompt's call — see shared/schema.yaml.
|
|
160
|
+
return stringify(await api.callService(name, args, executionContext));
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* THE HARNESS'S OWN ABSORBER, called once — which is what every legacy agent did
|
|
165
|
+
* (`runOpenAIAgent.ts:580`) and what this bridge stopped doing.
|
|
166
|
+
*
|
|
167
|
+
* It was re-implemented here as two halves, a mint and a lean, and the step BETWEEN them
|
|
168
|
+
* was simply absent: the shell-open. `handleDiscoveryResult` says so at the top of itself
|
|
169
|
+
* — "never re-implemented in an adapter" — and the reason is this exact failure. Nothing
|
|
170
|
+
* errored. The tool was minted, the model answered, and the page never appeared until the
|
|
171
|
+
* model composed it, with none of the `__draftRefs` that let the server paint the hero
|
|
172
|
+
* and galleries from the rows the search had already returned.
|
|
173
|
+
*
|
|
174
|
+
* `onAppUnlocked` is where the mint goes now: the harness decides WHEN an app is newly
|
|
175
|
+
* unlocked, and this decides what a tool looks like to the model. Awaited, so the tool is
|
|
176
|
+
* registered before the model's next turn reads it.
|
|
177
|
+
*/
|
|
178
|
+
async absorb(toolName, resultContent) {
|
|
179
|
+
const minted: any[] = [];
|
|
180
|
+
const content = await harness.handleDiscoveryResult(
|
|
181
|
+
toolName,
|
|
182
|
+
resultContent,
|
|
183
|
+
discoveredApps,
|
|
184
|
+
identity,
|
|
185
|
+
(m: string) => console.log(`[manifests] ${m}`),
|
|
186
|
+
async (mcp: any) => {
|
|
187
|
+
// The app's input schema is read LIVE from its URI at mint time; spatial rows
|
|
188
|
+
// carry no schema copy. For a BRIEFED component that schema is the instruction
|
|
189
|
+
// channel, so the model hydrates real fields instead of following prose.
|
|
190
|
+
const def = await harness.resolveToolDefNative(mcp, { accessToken: identity.accessToken });
|
|
191
|
+
const invoke = harness.componentAppInvoker(mcp, identity);
|
|
192
|
+
appInvokers.set(def.name, invoke);
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* THE PAGE FILLS BEFORE THE WRITER GETS THE PEN (owner ruling 2026-09-01 night).
|
|
196
|
+
*
|
|
197
|
+
* The shell-open renders the page skeleton and runs the page's OWN fill — its
|
|
198
|
+
* searches, the director's picks, the tiles (§9e). It used to be fire-and-forget
|
|
199
|
+
* while the tool was minted immediately, so the model composed captions for a page
|
|
200
|
+
* it had never seen; a read-first bounce tried to repair that and depended on the
|
|
201
|
+
* model re-sending, which it sometimes simply did not (observed 15:45: bounce, no
|
|
202
|
+
* resend, ghost headings shipped). So the ORDER is now physical: the shell call is
|
|
203
|
+
* AWAITED, and only then is the tool minted — with the page's chosen places in the
|
|
204
|
+
* tool's own description. The writer cannot write blind, and nothing needs bouncing.
|
|
205
|
+
*
|
|
206
|
+
* The guest loses nothing: the skeleton and the tiles render during this await; only
|
|
207
|
+
* the model's captions start those few seconds later. A failed shell mints the tool
|
|
208
|
+
* with its plain description — a page that arrives the old way, never a dead turn.
|
|
209
|
+
*
|
|
210
|
+
* `__draftRefs` carries the UNLOCKING SEARCH'S ranked row ids (the server drafts
|
|
211
|
+
* ref-only parts from them); the execution ids trace the page's searches into the
|
|
212
|
+
* run's own timeline.
|
|
213
|
+
*/
|
|
214
|
+
const draftRefs = harness.rankedRowIds(resultContent);
|
|
215
|
+
let chosenNote = "";
|
|
216
|
+
try {
|
|
217
|
+
const opened = await invoke({
|
|
218
|
+
__draftRefs: draftRefs,
|
|
219
|
+
...(lastAsk ? { __ask: lastAsk } : {}),
|
|
220
|
+
...(executionContext?.workflowId ? { __workflowId: executionContext.workflowId } : {}),
|
|
221
|
+
...(executionContext?.executionId ? { __executionId: executionContext.executionId } : {}),
|
|
222
|
+
...(executionContext?.nodeId ? { __nodeId: executionContext.nodeId } : {}),
|
|
223
|
+
});
|
|
224
|
+
const msg = typeof (opened as { message?: unknown })?.message === "string" ? (opened as { message: string }).message : "";
|
|
225
|
+
const chosen = msg.match(/THE PAGE'S CONTENT IS ALREADY CHOSEN — [^\n]*?never a different plan\./)?.[0];
|
|
226
|
+
if (chosen) chosenNote = `\n\n${chosen}`;
|
|
227
|
+
console.log(`[manifests] shell opened: ${def.name}${draftRefs.length ? ` (${draftRefs.length} draft refs)` : ""}${lastAsk ? ` ask="${lastAsk}"` : ""}${chosen ? " — chosen places ride the tool description" : ""}`);
|
|
228
|
+
} catch (e: any) {
|
|
229
|
+
console.log(`[manifests] shell open failed for ${def.name} — ${e?.message ?? e}`);
|
|
230
|
+
}
|
|
231
|
+
minted.push({ name: def.name, description: `${def.description}${chosenNote}`, parameters: def.parameters });
|
|
232
|
+
},
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* PATH-A apps, minted here because the absorber's callback does not cover them.
|
|
237
|
+
*
|
|
238
|
+
* `onAppUnlocked` fires only for a bindingless PATH-B component app — the kind that
|
|
239
|
+
* needs a shell-open and a live schema read. A workflow-bound app carries its contract
|
|
240
|
+
* on the row and needs neither, so the harness has nothing to unlock and never calls
|
|
241
|
+
* back. It still has to become a tool, which is what this does.
|
|
242
|
+
*
|
|
243
|
+
* Deduped downstream by tool name, so re-minting on a later search is harmless.
|
|
244
|
+
*/
|
|
245
|
+
if (harness.DISCOVERY_TOOL_NAMES.includes(toolName))
|
|
246
|
+
for (const mcp of harness.parseDiscoveredMCPs([{ name: toolName, resultContent }]))
|
|
247
|
+
if (!harness.isBindinglessComponentApp(mcp)) {
|
|
248
|
+
const def = harness.toolDefFromDiscoveredMCP(mcp);
|
|
249
|
+
minted.push({ name: def.name, description: def.description, parameters: def.parameters });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* SKILLS IN FORCE, lifted before the lean drops them. Handed back beside `minted`
|
|
254
|
+
* because they are the same kind of fact: something the reply UNLOCKED that the loop
|
|
255
|
+
* must act on, rather than anything the model asked for.
|
|
256
|
+
*/
|
|
257
|
+
const skills = harness.parseRegionSkills(resultContent);
|
|
258
|
+
|
|
259
|
+
return { content, minted, skills };
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
endsTurn(calls) {
|
|
263
|
+
return harness.isTurnEndingHandoff(calls, core);
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest node loading.
|
|
3
|
+
*
|
|
4
|
+
* Reads declarative node manifests from a SOURCE, composes each folder into one
|
|
5
|
+
* definition, and registers it through exactly the same path a code plugin uses. A
|
|
6
|
+
* manifest node is therefore indistinguishable downstream: same registry, same
|
|
7
|
+
* /nodes payload, same catalog ranking, same canvas.
|
|
8
|
+
*
|
|
9
|
+
* RELOADABLE BY DESIGN. `loadManifests` can be called again at any time and will
|
|
10
|
+
* replace what it loaded before. That is the point of the format: installing or
|
|
11
|
+
* updating a node should never restart a universe. When the source becomes Postgres,
|
|
12
|
+
* an install is a row write followed by this call.
|
|
13
|
+
*
|
|
14
|
+
* See docs/architecture/DECLARATIVE_NODES.md.
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
getNodeRegistry,
|
|
18
|
+
getPluginNodeMap,
|
|
19
|
+
getCredentialRegistry,
|
|
20
|
+
replaceCredentialType,
|
|
21
|
+
unregisterCredentialType,
|
|
22
|
+
deleteNode,
|
|
23
|
+
} from "../registry.js";
|
|
24
|
+
import { composeNode, composeCredentials, packageVersion, packageMeta, ManifestError, type ComposedNode, type PackageMeta } from "./compose.js";
|
|
25
|
+
import { hashNode, assertUnchanged, IntegrityError } from "./integrity.js";
|
|
26
|
+
import { executorForKind, setManifestLookup } from "./executor/index.js";
|
|
27
|
+
import { diskSource, rowsSource, type ManifestSource, type RawPackage } from "./source.js";
|
|
28
|
+
import { fetchNodeRows } from "./rows.js";
|
|
29
|
+
import { isPluginEnabled } from "../plugins/state.js";
|
|
30
|
+
import { NODES_HOME, databaseOnly } from "../paths.js";
|
|
31
|
+
import { boot } from "../boot.js";
|
|
32
|
+
|
|
33
|
+
/** Marketplace name for a manifest package, so it shares one row with its code half. */
|
|
34
|
+
export const packageIdFor = (name: string) => `@unoverse-platform/${name}`;
|
|
35
|
+
|
|
36
|
+
/** Packages the current source offers, whether enabled or not. */
|
|
37
|
+
export async function listManifestPackages(source: ManifestSource = diskSource(NODES_HOME)): Promise<string[]> {
|
|
38
|
+
return (await source.listPackages()).map((p) => packageIdFor(p.name));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function isManifestPackage(name: string, source?: ManifestSource): Promise<boolean> {
|
|
42
|
+
return (await listManifestPackages(source)).includes(name);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Display metadata per manifest package, keyed by marketplace name.
|
|
47
|
+
*
|
|
48
|
+
* A manifest package has no plugin-state row, so /plugins had nothing to show for it.
|
|
49
|
+
* Populated on every load and read by routePlugins.
|
|
50
|
+
*/
|
|
51
|
+
const packageMetas = new Map<string, PackageMeta>();
|
|
52
|
+
|
|
53
|
+
export function getManifestPackageMeta(name: string): PackageMeta | undefined {
|
|
54
|
+
return packageMetas.get(name);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Node types this loader owns, so a reload can retract exactly what it added. */
|
|
58
|
+
const loadedTypes = new Map<string, ComposedNode>();
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Credential types this loader owns.
|
|
62
|
+
*
|
|
63
|
+
* Tracked separately from the registry because retraction must be SURGICAL: a
|
|
64
|
+
* credential registered by a code plugin is not ours to remove, and clearing the whole
|
|
65
|
+
* registry on reload would take those with it. Only names in here are ever replaced or
|
|
66
|
+
* deleted.
|
|
67
|
+
*/
|
|
68
|
+
const loadedCredentials = new Set<string>();
|
|
69
|
+
|
|
70
|
+
export function getLoadedManifestCredentials(): string[] {
|
|
71
|
+
return [...loadedCredentials];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The two shared executors resolve their manifest by node type through this, which is
|
|
75
|
+
// what keeps them at two classes rather than one per node, and lets a reload swap a
|
|
76
|
+
// node's behaviour without re-registering anything.
|
|
77
|
+
setManifestLookup((type) => loadedTypes.get(type));
|
|
78
|
+
|
|
79
|
+
export function getManifestNode(type: string): ComposedNode | undefined {
|
|
80
|
+
return loadedTypes.get(type);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getLoadedManifestTypes(): string[] {
|
|
84
|
+
return [...loadedTypes.keys()];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface LoadResult {
|
|
88
|
+
nodes: number;
|
|
89
|
+
credentials: number;
|
|
90
|
+
errors: string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Where each loaded manifest package came from. Derived, never stored.
|
|
95
|
+
*
|
|
96
|
+
* A stored row goes stale: a package that moved from npm to manifests on disk kept
|
|
97
|
+
* rendering as installed, with a Remove button that would have disabled the manifest.
|
|
98
|
+
* `postgres:` origin means installed and removable; anything else is a folder on disk.
|
|
99
|
+
*/
|
|
100
|
+
export function manifestPackageOrigins(): Map<string, { source: "local" | "npm"; version: string }> {
|
|
101
|
+
const out = new Map<string, { source: "local" | "npm"; version: string }>();
|
|
102
|
+
for (const node of loadedTypes.values())
|
|
103
|
+
out.set(packageIdFor(node.packageName), {
|
|
104
|
+
source: node.origin.startsWith("postgres:") ? "npm" : "local",
|
|
105
|
+
version: node.packageVersion ?? "1.0.0",
|
|
106
|
+
});
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Load (or reload) every manifest node from `source`.
|
|
112
|
+
*
|
|
113
|
+
* A broken node is skipped with a named error and never takes the others down: one
|
|
114
|
+
* bad YAML file must not cost a universe its whole catalog.
|
|
115
|
+
*/
|
|
116
|
+
export async function loadManifests(source?: ManifestSource): Promise<LoadResult> {
|
|
117
|
+
const registry = getNodeRegistry();
|
|
118
|
+
const pluginNodeMap = getPluginNodeMap();
|
|
119
|
+
const result: LoadResult = { nodes: 0, credentials: 0, errors: [] };
|
|
120
|
+
|
|
121
|
+
// TWO sources, in precedence order, FIRST ONE WINS. An explicit source (tests, a
|
|
122
|
+
// one-off load) wins outright; otherwise DISK beats rows, because a manifest present
|
|
123
|
+
// in the monorepo is being authored right now and must beat whatever was installed.
|
|
124
|
+
// Same local-wins first-principle the node loader already applies to npm copies.
|
|
125
|
+
// Under UNOVERSE_DATABASE_ONLY the disk half is dropped, so a developer sees the node
|
|
126
|
+
// set a deployed universe would have: whatever is installed, and nothing else.
|
|
127
|
+
const sources: ManifestSource[] = source
|
|
128
|
+
? [source]
|
|
129
|
+
: databaseOnly()
|
|
130
|
+
? [rowsSource(fetchNodeRows)]
|
|
131
|
+
: [diskSource(NODES_HOME), rowsSource(fetchNodeRows)];
|
|
132
|
+
|
|
133
|
+
const packages: RawPackage[] = [];
|
|
134
|
+
const seen = new Set<string>();
|
|
135
|
+
for (const s of sources) {
|
|
136
|
+
let batch: RawPackage[];
|
|
137
|
+
try {
|
|
138
|
+
batch = await s.listPackages();
|
|
139
|
+
} catch (err: any) {
|
|
140
|
+
result.errors.push(`source "${s.name}" failed: ${err?.message ?? err}`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
for (const pkg of batch) {
|
|
144
|
+
// Merge by node type rather than by package: a MIXED package can have some nodes
|
|
145
|
+
// on disk and others only installed, and dropping the whole package because its
|
|
146
|
+
// name was seen would lose the ones that are only in the database.
|
|
147
|
+
const nodes = pkg.nodes.filter((n) => !seen.has(n.dir));
|
|
148
|
+
for (const n of nodes) seen.add(n.dir);
|
|
149
|
+
const existing = packages.find((p) => p.name === pkg.name);
|
|
150
|
+
if (existing) existing.nodes.push(...nodes);
|
|
151
|
+
else if (nodes.length || Object.keys(pkg.credentials).length) packages.push({ ...pkg, nodes });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (!packages.length) return result;
|
|
155
|
+
|
|
156
|
+
// Retract what a previous load added, so a reload cannot leave a deleted node or
|
|
157
|
+
// credential behind. Anything no longer present simply disappears.
|
|
158
|
+
const previous = new Set(loadedTypes.keys());
|
|
159
|
+
const previousCredentials = new Set(loadedCredentials);
|
|
160
|
+
loadedTypes.clear();
|
|
161
|
+
loadedCredentials.clear();
|
|
162
|
+
|
|
163
|
+
const credentialRegistry = getCredentialRegistry();
|
|
164
|
+
const disabledPackages: string[] = [];
|
|
165
|
+
|
|
166
|
+
packageMetas.clear();
|
|
167
|
+
for (const pkg of packages) {
|
|
168
|
+
packageMetas.set(packageIdFor(pkg.name), packageMeta(pkg));
|
|
169
|
+
// Disabled in the marketplace means GONE, not hidden: its nodes and credential
|
|
170
|
+
// types are retracted below exactly as if the package had been removed from the
|
|
171
|
+
// source. That is what makes uninstall work without a restart.
|
|
172
|
+
if (!isPluginEnabled(packageIdFor(pkg.name))) {
|
|
173
|
+
disabledPackages.push(pkg.name);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
for (const cred of composeCredentials(pkg)) {
|
|
179
|
+
// A credential a CODE plugin registered is not ours to overwrite: local source
|
|
180
|
+
// wins over a manifest, the same rule as for nodes.
|
|
181
|
+
if (credentialRegistry.has(cred.name) && !previousCredentials.has(cred.name)) {
|
|
182
|
+
result.errors.push(`${pkg.name}/credentials/${cred.name}: already registered by code — manifest skipped`);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
// REPLACE, not register: a package that adds a field to its credential must be
|
|
186
|
+
// able to land that change without a restart, and the first-wins path would
|
|
187
|
+
// keep the old shape forever.
|
|
188
|
+
replaceCredentialType(cred);
|
|
189
|
+
loadedCredentials.add(cred.name);
|
|
190
|
+
result.credentials++;
|
|
191
|
+
}
|
|
192
|
+
} catch (err: any) {
|
|
193
|
+
result.errors.push(err instanceof ManifestError ? err.message : `${pkg.name}/credentials: ${err?.message ?? err}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const types: string[] = [];
|
|
197
|
+
for (const raw of pkg.nodes) {
|
|
198
|
+
try {
|
|
199
|
+
const node = composeNode(raw, pkg);
|
|
200
|
+
node.hash = hashNode(node);
|
|
201
|
+
node.packageVersion = packageVersion(pkg);
|
|
202
|
+
|
|
203
|
+
// TAMPER CHECK, before the node reaches the registry. A source that recorded a
|
|
204
|
+
// hash at publish gets it verified now; disk records none and is governed by git
|
|
205
|
+
// instead. Failing here rather than at first execution is deliberate: a node that
|
|
206
|
+
// does not match what was approved must never become callable, not even once.
|
|
207
|
+
assertUnchanged(node, raw.hash, `${pkg.name}/${raw.dir}`);
|
|
208
|
+
|
|
209
|
+
// A code node of the same type already loaded. Never override it: local
|
|
210
|
+
// source wins over a manifest exactly as it wins over an npm copy, so a
|
|
211
|
+
// half-migrated package cannot silently swap a working executor for one that
|
|
212
|
+
// refuses to run.
|
|
213
|
+
if (registry.has(node.type) && !previous.has(node.type)) {
|
|
214
|
+
result.errors.push(`${pkg.name}/${raw.dir}: type "${node.type}" is already registered by code — manifest skipped`);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
registry.set(node.type, {
|
|
219
|
+
...(node.definition as any),
|
|
220
|
+
// ONE of two shared classes, never a class per node.
|
|
221
|
+
executor: executorForKind(node.kind),
|
|
222
|
+
executionMode: node.kind === "CallbackNode" ? "generator" : "single",
|
|
223
|
+
});
|
|
224
|
+
loadedTypes.set(node.type, node);
|
|
225
|
+
types.push(node.type);
|
|
226
|
+
result.nodes++;
|
|
227
|
+
} catch (err: any) {
|
|
228
|
+
// An integrity failure is not the same class of problem as a malformed manifest,
|
|
229
|
+
// so it says so and is never folded into the quiet "skipped" count below.
|
|
230
|
+
if (err instanceof IntegrityError) console.error(`[unoverse:manifests] TAMPER: ${err.message}`);
|
|
231
|
+
result.errors.push(
|
|
232
|
+
err instanceof ManifestError || err instanceof IntegrityError
|
|
233
|
+
? err.message
|
|
234
|
+
: `${pkg.name}/${raw.dir}: ${err?.message ?? err}`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (types.length) {
|
|
240
|
+
// Merge rather than replace: a MIXED package has code nodes here already, and
|
|
241
|
+
// dropping them would lose the package→types provenance /plugins reads.
|
|
242
|
+
const existing = pluginNodeMap.get(`@unoverse-platform/${pkg.name}`) ?? [];
|
|
243
|
+
pluginNodeMap.set(`@unoverse-platform/${pkg.name}`, [...new Set([...existing, ...types])]);
|
|
244
|
+
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Anything this loader owned last time and no longer produces is gone. Surgical on
|
|
249
|
+
// purpose: only names this loader registered are ever retracted, so uninstalling a
|
|
250
|
+
// manifest package cannot take a code plugin's node or credential with it.
|
|
251
|
+
for (const stale of previous) if (!loadedTypes.has(stale)) deleteNode(stale);
|
|
252
|
+
for (const stale of previousCredentials)
|
|
253
|
+
if (!loadedCredentials.has(stale) && unregisterCredentialType(stale))
|
|
254
|
+
console.log(`[unoverse:manifests] retracted credential type ${stale}`); // a real change of state
|
|
255
|
+
|
|
256
|
+
// At boot this is one row in the readout. A LATER reload (the watcher, an install,
|
|
257
|
+
// an uninstall) is an event on an otherwise quiet screen, so it still speaks.
|
|
258
|
+
if (boot.isBooting()) {
|
|
259
|
+
boot.manifests({
|
|
260
|
+
nodes: result.nodes,
|
|
261
|
+
credentials: result.credentials,
|
|
262
|
+
// "already registered by code" is the local-wins rule working, not a fault, so it
|
|
263
|
+
// is reported as a shadow with its type named rather than as an error.
|
|
264
|
+
shadowed: result.errors.flatMap((e) => {
|
|
265
|
+
const m = /type "([^"]+)" is already registered by code/.exec(e);
|
|
266
|
+
return m ? [m[1]] : [];
|
|
267
|
+
}),
|
|
268
|
+
disabled: disabledPackages,
|
|
269
|
+
});
|
|
270
|
+
for (const e of result.errors)
|
|
271
|
+
if (!/already registered by code/.test(e)) boot.notice("warn", `manifest skipped: ${e}`);
|
|
272
|
+
} else {
|
|
273
|
+
console.log(
|
|
274
|
+
`[unoverse:manifests] ${result.nodes} node(s), ${result.credentials} credential type(s) from ${sources.map((s) => s.name).join(" + ")}` +
|
|
275
|
+
(result.errors.length ? `, ${result.errors.length} skipped` : ""),
|
|
276
|
+
);
|
|
277
|
+
for (const e of result.errors) console.warn(` ! ${e}`);
|
|
278
|
+
}
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE CONTENT HASH. Its own file, like allowedHosts.ts, because it is a security control.
|
|
3
|
+
*
|
|
4
|
+
* AllowedHosts answers "what can this node do?". It does NOT answer "is this the node I
|
|
5
|
+
* approved?", and only the second question is about tampering. See SECURITY.md
|
|
6
|
+
* §"Manifest integrity — phase 1, the content hash".
|
|
7
|
+
*
|
|
8
|
+
* WHAT IS HASHED: the COMPOSED document, which includes the package envelope.
|
|
9
|
+
*
|
|
10
|
+
* Not the node's own files alone, and this is the whole point rather than a detail.
|
|
11
|
+
* `allowedHosts` is declared in package.yaml, so a hash over only api/run.yaml would leave the
|
|
12
|
+
* allowlist that bounds the node editable by anyone who can edit the node. Hashing the
|
|
13
|
+
* composition is what makes the allowedHosts declaration itself tamper-evident.
|
|
14
|
+
*
|
|
15
|
+
* Canonical JSON, not the YAML bytes, so reformatting or editing a comment does not break
|
|
16
|
+
* the seal while everything that actually EXECUTES stays covered. A comment cannot change
|
|
17
|
+
* behaviour; a changed URL, credential reference or expression always can.
|
|
18
|
+
*
|
|
19
|
+
* WHEN: at load, and only at load. Verification is a boot-time cost of milliseconds across
|
|
20
|
+
* a package. Nothing is hashed while a node is running, and nothing ever should be: a
|
|
21
|
+
* per-call check would be the one design that actually slows nodes down.
|
|
22
|
+
*
|
|
23
|
+
* WHAT THIS IS NOT: a hash stored beside the thing it protects is tamper EVIDENCE, not
|
|
24
|
+
* tamper PREVENTION. Someone with database write access can change the manifest and the
|
|
25
|
+
* hash in one transaction. It catches a hand-edited row, a stray script, drift between git
|
|
26
|
+
* and a universe, and corruption. Defeating an attacker who already owns the database is
|
|
27
|
+
* what signing is for, and that is phase 2.
|
|
28
|
+
*/
|
|
29
|
+
import { createHash } from "node:crypto";
|
|
30
|
+
import type { ComposedNode } from "./compose.js";
|
|
31
|
+
|
|
32
|
+
/** Prefixed so a stored value says what produced it, and a future scheme can coexist. */
|
|
33
|
+
const PREFIX = "sha256";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Canonical JSON: object keys sorted at every depth, arrays left alone.
|
|
37
|
+
*
|
|
38
|
+
* Sorting keys makes the hash independent of the order YAML happened to be written in, so
|
|
39
|
+
* moving `transport` above `auth` in a file is not a tamper. Array order is PRESERVED and
|
|
40
|
+
* must be: the order of `run` is the order the calls happen, and the order of `events` is
|
|
41
|
+
* the connector order lint enforces. Sorting those would erase real meaning.
|
|
42
|
+
*/
|
|
43
|
+
function canonical(value: unknown): unknown {
|
|
44
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
45
|
+
if (value && typeof value === "object") {
|
|
46
|
+
const out: Record<string, unknown> = {};
|
|
47
|
+
for (const key of Object.keys(value as Record<string, unknown>).sort())
|
|
48
|
+
out[key] = canonical((value as Record<string, unknown>)[key]);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The hash of a composed node: `sha256:<hex>`.
|
|
56
|
+
*
|
|
57
|
+
* `origin` is deliberately EXCLUDED. It is the absolute path on disk or a `postgres:` id,
|
|
58
|
+
* so including it would give the same node a different hash on every machine and make the
|
|
59
|
+
* value useless for comparing what a developer reviewed against what a universe is running.
|
|
60
|
+
* Everything else about the node is in scope, including `allowedHosts`.
|
|
61
|
+
*/
|
|
62
|
+
export function hashNode(node: ComposedNode): string {
|
|
63
|
+
const subject = {
|
|
64
|
+
type: node.type,
|
|
65
|
+
kind: node.kind,
|
|
66
|
+
packageName: node.packageName,
|
|
67
|
+
allowedHosts: node.allowedHosts,
|
|
68
|
+
definition: node.definition,
|
|
69
|
+
api: node.api,
|
|
70
|
+
};
|
|
71
|
+
return `${PREFIX}:${createHash("sha256").update(JSON.stringify(canonical(subject))).digest("hex")}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class IntegrityError extends Error {}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Refuse a node whose content does not match the hash its source recorded.
|
|
78
|
+
*
|
|
79
|
+
* A source with no expected hash is NOT an error: manifests on disk are governed by git,
|
|
80
|
+
* which is a better integrity control than a hash file sitting next to the thing it
|
|
81
|
+
* protects, and demanding one would make local authoring impossible. The check applies
|
|
82
|
+
* where a hash was recorded, which in practice means rows.
|
|
83
|
+
*/
|
|
84
|
+
export function assertUnchanged(node: ComposedNode, expected: string | undefined, where: string): void {
|
|
85
|
+
if (!expected) return;
|
|
86
|
+
const actual = hashNode(node);
|
|
87
|
+
if (actual === expected) return;
|
|
88
|
+
throw new IntegrityError(
|
|
89
|
+
`${where}: content hash mismatch. Recorded ${expected}, computed ${actual}. ` +
|
|
90
|
+
`This node's manifest has changed since it was published, so it will NOT be registered. ` +
|
|
91
|
+
`Re-deploy it from source rather than editing the stored copy (SECURITY.md §Manifest integrity).`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading installed node rows.
|
|
3
|
+
*
|
|
4
|
+
* The engine owns database access (this server has no Postgres client), so rows come
|
|
5
|
+
* over the same internal hop plugin state already uses. Kept in its own file so
|
|
6
|
+
* `source.ts` stays a pure shape-conversion with no transport in it.
|
|
7
|
+
*
|
|
8
|
+
* A failure here THROWS. It used to answer [] instead, which is indistinguishable from
|
|
9
|
+
* a universe that owns nothing, and on a deployed universe rows are the only node
|
|
10
|
+
* source, so a boot that lost the race to the engine silently served an empty catalog.
|
|
11
|
+
* The loader records a thrown source and carries on, so one dead source still cannot
|
|
12
|
+
* lose the other.
|
|
13
|
+
*/
|
|
14
|
+
import type { ItemRow } from "./source.js";
|
|
15
|
+
|
|
16
|
+
const ENGINE = process.env.WORKFLOW_SERVICE_URL || "http://localhost:4101";
|
|
17
|
+
|
|
18
|
+
export async function fetchNodeRows(): Promise<ItemRow[]> {
|
|
19
|
+
let res: Response;
|
|
20
|
+
try {
|
|
21
|
+
res = await fetch(`${ENGINE}/items?kind=node`);
|
|
22
|
+
} catch (error: any) {
|
|
23
|
+
// Unreachable: the engine is not up yet, or not where we think it is. Both are worth
|
|
24
|
+
// retrying, and neither means this universe has no nodes.
|
|
25
|
+
throw new Error(`could not reach the engine at ${ENGINE} to read node rows: ${error?.message ?? error}`);
|
|
26
|
+
}
|
|
27
|
+
if (!res.ok) throw new Error(`the engine answered ${res.status} reading node rows from ${ENGINE}/items?kind=node`);
|
|
28
|
+
|
|
29
|
+
const data = (await res.json()) as { items?: Array<{ name: string; definition: any; enabled?: boolean }> };
|
|
30
|
+
// A disabled row is retracted, not hidden: it must not register, exactly as an
|
|
31
|
+
// uninstalled one would not. Enabled is the running state, presence is possession.
|
|
32
|
+
return (data.items ?? []).filter((r) => r.enabled !== false).map((r) => ({ name: r.name, definition: r.definition }));
|
|
33
|
+
}
|