@womp/spark-agent 4.4.1 → 5.0.1

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.
Files changed (45) hide show
  1. package/README.md +23 -2
  2. package/dist/capabilities/geometry.d.ts +0 -3
  3. package/dist/capabilities/geometry.js +7 -8
  4. package/dist/capabilities/openscad-instructions.d.ts +1 -1
  5. package/dist/capabilities/openscad-instructions.js +80 -81
  6. package/dist/capabilities/openscad.d.ts +0 -3
  7. package/dist/capabilities/openscad.js +1 -2
  8. package/dist/capabilities/scene.d.ts +2 -6
  9. package/dist/capabilities/scene.js +44 -45
  10. package/dist/generated/geometry-declarations.d.ts +1 -1
  11. package/dist/generated/geometry-declarations.js +1 -1
  12. package/dist/generated/openscad-declarations.d.ts +1 -1
  13. package/dist/generated/openscad-declarations.js +1 -1
  14. package/dist/generated/scene-declarations.d.ts +1 -1
  15. package/dist/generated/scene-declarations.js +1 -1
  16. package/dist/native-executor.js +2 -2
  17. package/dist/openscad.d.ts +1 -2
  18. package/dist/openscad.js +1 -19
  19. package/dist/quickjs-executor.js +1 -1
  20. package/dist/repair-state.d.ts +2 -0
  21. package/dist/repair-state.js +15 -0
  22. package/dist/runtime.d.ts +1 -5
  23. package/dist/runtime.js +8 -14
  24. package/dist/scene-environment.js +1 -1
  25. package/dist/scene-health.d.ts +1 -2
  26. package/dist/scene-health.js +0 -17
  27. package/dist/scene-operations.d.ts +5 -5
  28. package/dist/scene-operations.js +24 -43
  29. package/dist/sdk-geometry-host.d.ts +2 -2
  30. package/dist/sdk-geometry-host.js +44 -61
  31. package/dist/sdk-openscad-host.js +63 -102
  32. package/dist/sdk-scene-health-host.d.ts +2 -1
  33. package/dist/sdk-scene-health-host.js +54 -4
  34. package/dist/sdk-scene-host.d.ts +20 -5
  35. package/dist/sdk-scene-host.js +34 -31
  36. package/dist/sdk-scene-screenshot.d.ts +1 -1
  37. package/dist/sdk-scene-screenshot.js +4 -3
  38. package/dist/server/scene-access.d.ts +4 -0
  39. package/dist/server/scene-access.js +40 -0
  40. package/dist/server/standalone.js +24 -19
  41. package/dist/womp-scene-api.d.ts +15 -0
  42. package/dist/womp-scene-api.js +18 -1
  43. package/package.json +139 -139
  44. package/dist/server/scene-initialization.d.ts +0 -3
  45. package/dist/server/scene-initialization.js +0 -44
@@ -1,4 +1,5 @@
1
- import { getSdkSceneAreaId, getSdkSceneAreaIds, readStableSdkSceneHealth, } from "./scene-health.js";
1
+ import { getSdkSceneAreaId, getSdkSceneAreaIds, readSdkSceneHealth, parseSceneHealth, } from "./scene-health.js";
2
+ import { createGeometryRepairTracker } from "./repair-state.js";
2
3
  /**
3
4
  * The Area that owns `objectId`, or undefined when the node is in no Area --
4
5
  * the scene root 0, a root-level Group, a Light. The engine's thermometer has a
@@ -9,25 +10,74 @@ function owningAreaId(api, objectId) {
9
10
  const areaId = getSdkSceneAreaId(api, objectId);
10
11
  return api.getNode(Number(areaId)).kind === "union" ? areaId : undefined;
11
12
  }
12
- export function createSdkSceneHealthHost(editor, onHealth) {
13
+ // Health is requested explicitly. Feed its checked Areas back to the same
14
+ // target budgets used by writes, without spending an attempt or settling an
15
+ // operation. A partial reading must not clear a linked target's other Areas.
16
+ async function observeRepairs(api, health, store) {
17
+ if (health.thermometer === undefined)
18
+ return;
19
+ const targets = new Map();
20
+ const add = (namespace, target, objectId) => {
21
+ const area = owningAreaId(api, objectId);
22
+ if (area === undefined)
23
+ return;
24
+ const key = `${namespace}:${target}`;
25
+ const entry = targets.get(key) ?? { namespace, target, areas: new Set() };
26
+ entry.areas.add(area);
27
+ targets.set(key, entry);
28
+ };
29
+ const unions = api.listNodes({ kind: "union" });
30
+ const scripts = api.getSceneStorage().scripts;
31
+ const instances = scripts && typeof scripts === "object" && !Array.isArray(scripts)
32
+ ? scripts.instances : undefined;
33
+ for (const node of unions) {
34
+ if (node.kind !== "union" || node.scriptInstanceId === undefined)
35
+ continue;
36
+ const instance = instances && typeof instances === "object" && !Array.isArray(instances)
37
+ ? instances[node.scriptInstanceId] : undefined;
38
+ if (instance && typeof instance === "object" && !Array.isArray(instance)
39
+ && typeof instance.graph_id === "number") {
40
+ add("geometry", String(instance.graph_id), node.id);
41
+ }
42
+ }
43
+ for (const node of api.listNodes({ kind: "openScad" })) {
44
+ if (node.kind === "openScad")
45
+ add("openscad.geometry", `script:${node.scriptId}`, node.id);
46
+ }
47
+ const checked = new Set(health.affectedAreaIds);
48
+ for (const { namespace, target, areas } of targets.values()) {
49
+ if ([...areas].some((id) => !checked.has(id)))
50
+ continue;
51
+ const scoped = parseSceneHealth(health.thermometer, [...areas]);
52
+ // A zero-count Area does not prove that its output is healthy, even if
53
+ // another Area in the full thermometer has an overflow flag.
54
+ if (scoped.emptyAreaIds?.some((id) => areas.has(id)))
55
+ continue;
56
+ await createGeometryRepairTracker(store, namespace).observe(target, scoped);
57
+ }
58
+ }
59
+ export function createSdkSceneHealthHost(editor, onHealth, repairs) {
13
60
  return {
14
61
  read: (objectIds, context) => editor.read(context, async (api) => {
15
62
  const owned = objectIds
16
63
  .map((id) => ({ id, areaId: owningAreaId(api, id) }))
17
64
  .filter((entry) => entry.areaId !== undefined);
18
- // `scene.read()` publishes node 0, and ids outside any Area are legal
65
+ // `scene.access()` publishes node 0, and ids outside any Area are legal
19
66
  // arguments. They narrow nothing, so they are reported rather than
20
67
  // allowed to make the whole reading unverified; with nothing left to
21
68
  // narrow by, this is the whole-scene read `health()` already means.
22
69
  const outside = objectIds.filter((id) => owned.some((entry) => entry.id === id) === false);
23
70
  const areaIds = owned.length > 0 ? owned.map((entry) => entry.areaId) : getSdkSceneAreaIds(api);
24
- const health = await readStableSdkSceneHealth(api, [...new Set(areaIds)], context.signal);
71
+ const health = await readSdkSceneHealth(api, [...new Set(areaIds)], context.signal);
25
72
  const note = outside.length === 0
26
73
  ? undefined
27
74
  : `${outside.join(", ")} ${outside.length === 1 ? "is" : "are"} in no Area, and the engine reports health per Area, so ${owned.length > 0 ? "health is reported for the remaining ids" : "this is the whole scene's health"}.`;
28
75
  const reported = note === undefined
29
76
  ? health
30
77
  : { ...health, reason: health.reason === undefined ? note : `${health.reason} ${note}` };
78
+ context.signal.throwIfAborted();
79
+ if (repairs)
80
+ await observeRepairs(api, reported, repairs);
31
81
  onHealth?.(reported);
32
82
  return reported;
33
83
  }),
@@ -1,4 +1,4 @@
1
- import type { FontFamily, KakapoAPI, SceneEdit, MaterialResource } from "@womp/kakapo-sdk";
1
+ import type { FontFamily, KakapoAPI, SceneEdit, MaterialResource, NativeSceneSnapshot } from "@womp/kakapo-sdk";
2
2
  import type { ArtifactRef, SparkInvocationContext } from "./capabilities.js";
3
3
  import type { SceneHost, SceneHealthHost, SceneScreenshotOptions } from "./capabilities/scene.js";
4
4
  import { type SceneOperation, type SceneOperationStore } from "./scene-operations.js";
@@ -24,10 +24,10 @@ export type SdkSceneEditorOptions = {
24
24
  /** Supply this when the host adds a stricter connection lifetime. */
25
25
  context?: SparkInvocationContext) => Promise<T>): Promise<T>;
26
26
  assertEditable(context: SparkInvocationContext): void | Promise<void>;
27
- /** Acquire after preparation. Cleanup receives false until rollback or host synchronization is confirmed. */
28
- prepareCommit?(operation: SceneOperation, context: SparkInvocationContext): Promise<(synchronized: boolean) => void | Promise<void>>;
27
+ /** Suspend for the complete access, including reads and failed preparation. */
28
+ acquireAccess?(context: SparkInvocationContext): Promise<(synchronized: boolean) => void | Promise<void>>;
29
29
  /** Must adopt each operation ID at most once, including during recovery. */
30
- onCommitted?(operation: SceneOperation, api: KakapoAPI, context: SparkInvocationContext, identity: SdkSceneEditIdentity): Promise<Record<string, unknown> | void>;
30
+ onCommitted?(operation: SceneOperation, api: KakapoAPI, context: SparkInvocationContext, identity: SdkSceneEditIdentity, scene: NativeSceneSnapshot): Promise<Record<string, unknown> | void>;
31
31
  };
32
32
  export type SdkSceneEditIdentity = {
33
33
  capability: string;
@@ -36,8 +36,9 @@ export type SdkSceneEditIdentity = {
36
36
  };
37
37
  export type SdkSceneEditor = {
38
38
  getOperation(operationId: string): Promise<SceneOperation | undefined>;
39
+ withConnection<T>(context: SparkInvocationContext, action: (api: KakapoAPI, beginSettlement: () => void) => T | Promise<T>): Promise<T>;
39
40
  read<T>(context: SparkInvocationContext, action: (api: KakapoAPI, beginSettlement: () => void) => T | Promise<T>): Promise<T>;
40
- edit<T>(identity: SdkSceneEditIdentity, callback: (api: KakapoAPI, draft: SceneEdit, operationId: string) => T | Promise<T>, expectedRevision: string, context: SparkInvocationContext, afterCommitted?: (operation: SceneOperation, api: KakapoAPI) => Promise<Record<string, unknown> | void>): Promise<{
41
+ access<T>(identity: SdkSceneEditIdentity, callback: (api: KakapoAPI, draft: SceneEdit, operationId: string) => T | Promise<T>, context: SparkInvocationContext, afterCommitted?: (operation: SceneOperation, api: KakapoAPI) => Promise<Record<string, unknown> | void>): Promise<{
41
42
  value: T;
42
43
  operation: SceneOperation;
43
44
  }>;
@@ -308,6 +309,20 @@ export declare function createSdkSceneDraft(api: KakapoAPI, scene: SceneEdit, se
308
309
  g: number;
309
310
  b: number;
310
311
  }) => void;
312
+ unitMultiplier: number;
313
+ nodes(options?: {
314
+ ids?: number[];
315
+ type?: number;
316
+ parentId?: number;
317
+ name?: string;
318
+ offset?: number;
319
+ limit?: number;
320
+ }): {
321
+ id: number;
322
+ name: string;
323
+ type: number;
324
+ parentId: number;
325
+ }[];
311
326
  };
312
327
  export declare function createSdkSceneEditor(options: SdkSceneEditorOptions): SdkSceneEditor;
313
328
  export declare function createSdkSceneHost(options: SdkSceneEditorOptions & {
@@ -1,8 +1,15 @@
1
- import { createWompSceneApi, sceneNodeType } from "./womp-scene-api.js";
2
- import { sceneUnitMultiplier } from "./coordinates.js";
1
+ import { createWompSceneApi } from "./womp-scene-api.js";
3
2
  import { executeSceneOperation, } from "./scene-operations.js";
4
3
  import { readMaterialMetadata, writeMaterialMetadata, } from "./scene-metadata.js";
5
4
  import { boundedReceiptDetails } from "./scene-health.js";
5
+ function assertSceneSaved(operation) {
6
+ if (!operation.changed)
7
+ return;
8
+ if (operation.save?.status === "failed")
9
+ throw new Error(`The scene edit was applied, but the save failed: ${operation.save.error} Do not repeat the edit.`);
10
+ if (operation.save?.status === "unknown" || operation.save?.status === "requested")
11
+ throw new Error("The scene edit was applied, but its save outcome is unknown. Inspect persistence before continuing; do not repeat the edit.");
12
+ }
6
13
  export function createSdkSceneDraft(api, scene, services) {
7
14
  let defaultMaterialId = scene
8
15
  .listMaterials()
@@ -59,13 +66,17 @@ export function createSdkSceneEditor(options) {
59
66
  const ordinals = new WeakMap();
60
67
  return {
61
68
  getOperation: (operationId) => options.operations.getSceneOperation(options.targetKey, operationId),
69
+ withConnection: (context, action) => options.withConnection(context, async (api, settle, active = context) => {
70
+ active.signal.throwIfAborted();
71
+ return action(api, settle);
72
+ }),
62
73
  read: (context, action) => options.withConnection(context, async (api, beginSettlement, activeContext = context) => {
63
74
  activeContext.signal.throwIfAborted();
64
- await api.refreshScene();
75
+ await api.refreshScene({ returnScene: false });
65
76
  activeContext.signal.throwIfAborted();
66
77
  return action(api, beginSettlement);
67
78
  }),
68
- edit: (identity, callback, expectedRevision, context, afterCommitted) => {
79
+ access: (identity, callback, context, afterCommitted) => {
69
80
  const operationKey = `${identity.capability}.${identity.operation}`;
70
81
  const contextOrdinals = ordinals.get(context) ?? new Map();
71
82
  ordinals.set(context, contextOrdinals);
@@ -89,6 +100,8 @@ export function createSdkSceneEditor(options) {
89
100
  });
90
101
  let releaseCommit;
91
102
  let synchronized = false;
103
+ let saveSettled = true;
104
+ let committedScene;
92
105
  let hostDetails = {};
93
106
  const record = async (operation, details) => {
94
107
  hostDetails = { ...hostDetails, ...details };
@@ -113,6 +126,8 @@ export function createSdkSceneEditor(options) {
113
126
  });
114
127
  };
115
128
  try {
129
+ releaseCommit = await options.acquireAccess?.(activeContext);
130
+ synchronized = true;
116
131
  let result;
117
132
  try {
118
133
  result = await executeSceneOperation({
@@ -122,20 +137,20 @@ export function createSdkSceneEditor(options) {
122
137
  operation: {
123
138
  id,
124
139
  targetKey: options.targetKey,
125
- expectedRevision,
126
140
  },
127
141
  assertEditable: async () => {
128
142
  activeContext.signal.throwIfAborted();
129
143
  await options.assertEditable(activeContext);
130
144
  },
131
145
  edit: (draft) => callback(api, draft, id),
146
+ onCommittedScene: scene => { committedScene = scene; },
132
147
  prepareCommit: async (operation) => {
133
148
  await activeContext.recordReceipt({
134
149
  ...receipt(),
135
150
  status: "unknown",
136
151
  });
137
- releaseCommit = await options.prepareCommit?.(operation, activeContext);
138
152
  beginSettlement();
153
+ synchronized = false;
139
154
  },
140
155
  });
141
156
  }
@@ -153,12 +168,19 @@ export function createSdkSceneEditor(options) {
153
168
  }
154
169
  throw error;
155
170
  }
171
+ saveSettled = !result.operation.changed || !["unknown", "requested"].includes(result.operation.save?.status ?? "");
172
+ // A duplicate returns its journaled result; retain its original receipt
173
+ // and let explicit recovery handle any earlier synchronization failure.
174
+ if (result.reused) {
175
+ assertSceneSaved(result.operation);
176
+ return result;
177
+ }
156
178
  synchronized = !result.operation.changed || !options.onCommitted;
157
179
  await record(result.operation);
158
180
  if (!synchronized) {
159
181
  let details;
160
182
  try {
161
- details = await options.onCommitted(result.operation, api, activeContext, identity);
183
+ details = await options.onCommitted(result.operation, api, activeContext, identity, committedScene);
162
184
  synchronized = true;
163
185
  }
164
186
  catch (error) {
@@ -175,11 +197,7 @@ export function createSdkSceneEditor(options) {
175
197
  await record(result.operation, details || undefined);
176
198
  }
177
199
  if (result.operation.changed) {
178
- if (result.operation.save?.status === "failed")
179
- throw new Error(`The scene edit was applied, but the save failed: ${result.operation.save.error} Do not repeat the edit.`);
180
- if (result.operation.save?.status === "unknown" ||
181
- result.operation.save?.status === "requested")
182
- throw new Error("The scene edit was applied, but its save outcome is unknown. Inspect persistence before continuing; do not repeat the edit.");
200
+ assertSceneSaved(result.operation);
183
201
  const details = await afterCommitted?.(result.operation, api);
184
202
  if (details)
185
203
  await record(result.operation, details);
@@ -187,7 +205,7 @@ export function createSdkSceneEditor(options) {
187
205
  return result;
188
206
  }
189
207
  finally {
190
- await releaseCommit?.(synchronized);
208
+ await releaseCommit?.(synchronized && saveSettled);
191
209
  }
192
210
  });
193
211
  },
@@ -197,23 +215,8 @@ export function createSdkSceneHost(options) {
197
215
  const editor = options.editor ?? createSdkSceneEditor(options);
198
216
  return {
199
217
  health: options.health?.read.bind(options.health),
200
- read: (context) => editor.read(context, async (api) => {
201
- const scene = api.getScene();
202
- const revision = await api.getSceneRevision();
203
- return {
204
- revision,
205
- projectId: options.projectId,
206
- unitMultiplier: sceneUnitMultiplier(scene.properties.unit_multiplier),
207
- nodes: Object.values(scene.nodes).map(({ id, name, kind, parentId }) => ({
208
- id,
209
- name,
210
- type: sceneNodeType(kind),
211
- parentId: parentId ?? 0,
212
- })),
213
- };
214
- }),
215
- edit: (callback, expectedRevision, context) => editor
216
- .edit({ capability: "scene", operation: "edit" }, async (api, draft) => {
218
+ access: (callback, context) => editor
219
+ .access({ capability: "scene", operation: "access" }, async (api, draft) => {
217
220
  const services = await options.services(api, context);
218
221
  let active = true;
219
222
  const scopedDraft = new Proxy(draft, {
@@ -234,7 +237,7 @@ export function createSdkSceneHost(options) {
234
237
  finally {
235
238
  active = false;
236
239
  }
237
- }, expectedRevision, context, options.onSceneCommitted)
240
+ }, context, options.onSceneCommitted)
238
241
  .then(({ value }) => value),
239
242
  screenshot: options.screenshot,
240
243
  };
@@ -1,5 +1,5 @@
1
1
  import type { KakapoAPI } from "@womp/kakapo-sdk";
2
2
  import type { SceneScreenshotOptions } from "./capabilities/scene.js";
3
3
  import type { ExecutionScreenshot } from "./types.js";
4
- /** Convert Womp scene coordinates at the host boundary; never fit a camera implicitly. */
4
+ /** Convert explicit cameras at the native boundary; never fit or move the default view. */
5
5
  export declare function captureSdkSceneScreenshot(api: KakapoAPI, input: SceneScreenshotOptions): Promise<ExecutionScreenshot>;
@@ -1,10 +1,11 @@
1
1
  import { INTERNAL_UNITS_PER_MM, sceneUnitMultiplier, toEnginePosition } from "./coordinates.js";
2
- /** Convert Womp scene coordinates at the host boundary; never fit a camera implicitly. */
2
+ /** Convert explicit cameras at the native boundary; never fit or move the default view. */
3
3
  export async function captureSdkSceneScreenshot(api, input) {
4
4
  if (!input.camera)
5
5
  return api.captureScreenshot({ timeoutMs: 30_000 });
6
- const scene = await api.refreshScene();
7
- const units = sceneUnitMultiplier(scene.properties.unit_multiplier);
6
+ // Read authoritative project units without returning another full-scene copy.
7
+ await api.refreshScene({ returnScene: false });
8
+ const units = sceneUnitMultiplier(api.getSceneProperties().unit_multiplier);
8
9
  const camera = {
9
10
  ...input.camera,
10
11
  position: toEnginePosition(input.camera.position, units),
@@ -0,0 +1,4 @@
1
+ export declare function standaloneSceneAccess(url: string): {
2
+ release(synchronized: boolean): void;
3
+ run<T>(signal: AbortSignal, action: (signal: AbortSignal, beginSettlement: () => void) => Promise<T>): Promise<T>;
4
+ };
@@ -0,0 +1,40 @@
1
+ /** One queue per native renderer, shared by every standalone run in this process. */
2
+ const renderers = new Map();
3
+ export function standaloneSceneAccess(url) {
4
+ const key = new URL(url).href;
5
+ let access = renderers.get(key);
6
+ if (!access) {
7
+ access = createSceneAccess();
8
+ renderers.set(key, access);
9
+ }
10
+ return access;
11
+ }
12
+ function createSceneAccess() {
13
+ let tail = Promise.resolve();
14
+ let uncertain = false;
15
+ return {
16
+ release(synchronized) {
17
+ if (!synchronized)
18
+ uncertain = true;
19
+ },
20
+ run(signal, action) {
21
+ const result = tail.then(async () => {
22
+ signal.throwIfAborted();
23
+ if (uncertain)
24
+ throw new Error("The renderer has an uncertain scene commit. Inspect it before restarting standalone scene access; do not repeat the edit.");
25
+ const active = new AbortController();
26
+ const abort = () => active.abort(signal.reason);
27
+ signal.addEventListener("abort", abort, { once: true });
28
+ const beginSettlement = () => signal.removeEventListener("abort", abort);
29
+ try {
30
+ return await action(active.signal, beginSettlement);
31
+ }
32
+ finally {
33
+ beginSettlement();
34
+ }
35
+ });
36
+ tail = result.then(() => { }, () => { });
37
+ return result;
38
+ },
39
+ };
40
+ }
@@ -3,7 +3,7 @@ import { createOpenAI } from "@ai-sdk/openai";
3
3
  import { createGoogleGenerativeAI } from "@ai-sdk/google";
4
4
  import express from "express";
5
5
  import { getSparkModel, SPARK_MODELS } from "../models.js";
6
- import { KakapoAPI, KakapoRpcError } from "@womp/kakapo-sdk";
6
+ import { KakapoAPI, KakapoRpcError, KakapoSceneCommitError } from "@womp/kakapo-sdk";
7
7
  import { captureSdkSceneScreenshot } from "../sdk-scene-screenshot.js";
8
8
  import { composeCapabilities, createAccountCapability, createGeometryCapability, createOpenScadCapability, createSceneCapability, } from "../capabilities.js";
9
9
  import { loadDemoSceneMaterials } from "./demo-scene-materials.js";
@@ -13,7 +13,7 @@ import { createSdkGeometryHost } from "../sdk-geometry-host.js";
13
13
  import { createSdkOpenScadHost } from "../sdk-openscad-host.js";
14
14
  import { createSdkSceneHealthHost } from "../sdk-scene-health-host.js";
15
15
  import { geometryReference } from "../geometry.js";
16
- import { getSdkSceneAreaIds, hasSceneResourcePressure, readStableSdkSceneHealth, } from "../scene-health.js";
16
+ import { hasSceneResourcePressure, } from "../scene-health.js";
17
17
  import { QuickJSJavaScriptExecutor } from "../quickjs-executor.js";
18
18
  import { sparkModelOptions } from "../model-options.js";
19
19
  import { createSparkRunExecutor } from "./executor.js";
@@ -21,7 +21,7 @@ import { createSparkAgentRouter } from "./express.js";
21
21
  import { SparkAgentService } from "./service.js";
22
22
  import { SqliteSparkStore } from "./sqlite-store.js";
23
23
  import { clearSceneContents } from "./scene-actions.js";
24
- import { initializeStandaloneScene } from "./scene-initialization.js";
24
+ import { standaloneSceneAccess } from "./scene-access.js";
25
25
  export async function checkStandaloneServices(api) {
26
26
  const [fonts, openScad] = await Promise.allSettled([
27
27
  api.listFonts(),
@@ -57,6 +57,7 @@ export async function createStandaloneSparkAgentApp(options) {
57
57
  throw error;
58
58
  }
59
59
  const artifactStore = store;
60
+ const sceneAccess = standaloneSceneAccess(options.kakapoUrl);
60
61
  const executeRun = createSparkRunExecutor({
61
62
  createHost: async (context) => {
62
63
  const api = new KakapoAPI({
@@ -65,9 +66,7 @@ export async function createStandaloneSparkAgentApp(options) {
65
66
  });
66
67
  try {
67
68
  context.signal.throwIfAborted();
68
- await api.connect();
69
- context.signal.throwIfAborted();
70
- await initializeStandaloneScene(api, context.signal);
69
+ await api.connect({ refreshScene: false });
71
70
  context.signal.throwIfAborted();
72
71
  const support = await checkStandaloneServices(api);
73
72
  const services = {
@@ -81,12 +80,12 @@ export async function createStandaloneSparkAgentApp(options) {
81
80
  targetKey,
82
81
  operations: store,
83
82
  saveToRenderer: true,
84
- withConnection: async (invocation, action) => {
85
- invocation.signal.throwIfAborted();
83
+ withConnection: (invocation, action) => sceneAccess.run(invocation.signal, async (signal, beginSettlement) => {
86
84
  if (!api.isConnected)
87
85
  throw new Error("The Kakapo connection was interrupted; reconnect before continuing");
88
- return action(api, () => { }, invocation);
89
- },
86
+ return action(api, beginSettlement, { ...invocation, signal });
87
+ }),
88
+ acquireAccess: async () => sceneAccess.release,
90
89
  assertEditable: () => {
91
90
  if (!api.isConnected)
92
91
  throw new Error("The Kakapo connection is unavailable");
@@ -103,16 +102,11 @@ export async function createStandaloneSparkAgentApp(options) {
103
102
  const sceneHost = createSdkSceneHost({
104
103
  ...editorOptions,
105
104
  editor,
106
- health: createSdkSceneHealthHost(editor, onHealth),
107
- onSceneCommitted: async (_operation, api) => {
108
- const sceneHealth = await readStableSdkSceneHealth(api, getSdkSceneAreaIds(api), context.signal);
109
- onHealth(sceneHealth);
110
- return { sceneHealth };
111
- },
105
+ health: createSdkSceneHealthHost(editor, onHealth, repairs),
112
106
  services: async () => services,
113
107
  screenshot: async (settings, invocation) => {
114
108
  invocation.signal.throwIfAborted();
115
- const image = await captureSdkSceneScreenshot(api, settings);
109
+ const image = await editor.withConnection(invocation, activeApi => captureSdkSceneScreenshot(activeApi, settings));
116
110
  options.onScreenshot?.(context.runId);
117
111
  invocation.signal.throwIfAborted();
118
112
  return artifactStore.write(context.ownerId, image);
@@ -254,8 +248,19 @@ export async function createStandaloneSparkAgentApp(options) {
254
248
  clearingScene = true;
255
249
  const api = new KakapoAPI({ url: options.kakapoUrl, requestTimeoutMs: 30_000 });
256
250
  try {
257
- await api.connect();
258
- const { save } = await api.editSceneAndSave(clearSceneContents);
251
+ await api.connect({ refreshScene: false });
252
+ const { save } = await sceneAccess.run(new AbortController().signal, async () => {
253
+ try {
254
+ const result = await api.accessScene(clearSceneContents, { save: true });
255
+ sceneAccess.release(result.save.status !== "unknown");
256
+ return result;
257
+ }
258
+ catch (error) {
259
+ if (error instanceof KakapoSceneCommitError && error.outcome === "unknown")
260
+ sceneAccess.release(false);
261
+ throw error;
262
+ }
263
+ });
259
264
  const warning = save.status === "failed" || save.status === "unknown"
260
265
  ? `Scene cleared, but saving ${save.status === "failed" ? "failed" : "could not be confirmed"}: ${save.error} Do not repeat the clear operation.`
261
266
  : undefined;
@@ -306,5 +306,20 @@ export declare function createWompSceneApi(context: WompSceneApiContext): {
306
306
  g: number;
307
307
  b: number;
308
308
  }) => void;
309
+ unitMultiplier: number;
310
+ /** Current draft summaries. Filters precede pagination; defaults to 100 results, maximum 1000. */
311
+ nodes(options?: {
312
+ ids?: number[];
313
+ type?: number;
314
+ parentId?: number;
315
+ name?: string;
316
+ offset?: number;
317
+ limit?: number;
318
+ }): {
319
+ id: number;
320
+ name: string;
321
+ type: number;
322
+ parentId: number;
323
+ }[];
309
324
  };
310
325
  export {};
@@ -436,6 +436,23 @@ export function createWompSceneApi(context) {
436
436
  return converted;
437
437
  };
438
438
  const api = {
439
+ get unitMultiplier() {
440
+ return sceneUnitMultiplier(scene.getSceneProperties().unit_multiplier);
441
+ },
442
+ /** Current draft summaries. Filters precede pagination; defaults to 100 results, maximum 1000. */
443
+ nodes(options = {}) {
444
+ const { offset = 0, limit = 100 } = options;
445
+ if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 1000)
446
+ throw new Error("nodes() requires a nonnegative integer offset and a limit from 1 to 1000");
447
+ const ids = options.ids === undefined ? undefined : new Set(options.ids);
448
+ const query = options.name?.toLowerCase();
449
+ return scene.listNodeSummaries().filter(({ id, name, kind, parentId }) => (ids === undefined || ids.has(id)) &&
450
+ (options.type === undefined || sceneNodeType(kind) === options.type) &&
451
+ (options.parentId === undefined || (parentId ?? 0) === options.parentId) &&
452
+ (query === undefined || name.toLowerCase().includes(query))).slice(offset, offset + limit).map(({ id, name, kind, parentId }) => ({
453
+ id, name, type: sceneNodeType(kind), parentId: parentId ?? 0,
454
+ }));
455
+ },
439
456
  ...createSceneEnvironment(scene, context.hdrs, context.print),
440
457
  listSuperMaterials: () => {
441
458
  if (!context.superMaterialNames)
@@ -611,7 +628,7 @@ export function createWompSceneApi(context) {
611
628
  nodeSetLock: (id, locked) => {
612
629
  requireNode(id);
613
630
  const current = metadataObject(scene.getSceneExt([String(id)])[String(id)]);
614
- scene.patchSceneExt([{ op: "add", path: `/${id}`, value: { ...current, locked: Boolean(locked) } }]);
631
+ scene.patchSceneExt([{ op: "add", path: `/${id}`, value: { ...current, locked: Boolean(locked) } }], { returnScene: false });
615
632
  },
616
633
  nodeSetPos: (id, position) => {
617
634
  requireEditableTransform(id);