@womp/spark-agent 4.4.1 → 5.0.0

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 (46) hide show
  1. package/.env.example +6 -0
  2. package/README.md +21 -2
  3. package/dist/capabilities/geometry.d.ts +0 -3
  4. package/dist/capabilities/geometry.js +7 -8
  5. package/dist/capabilities/openscad-instructions.d.ts +1 -1
  6. package/dist/capabilities/openscad-instructions.js +80 -81
  7. package/dist/capabilities/openscad.d.ts +0 -3
  8. package/dist/capabilities/openscad.js +1 -2
  9. package/dist/capabilities/scene.d.ts +4 -11
  10. package/dist/capabilities/scene.js +38 -55
  11. package/dist/generated/geometry-declarations.d.ts +1 -1
  12. package/dist/generated/geometry-declarations.js +1 -1
  13. package/dist/generated/openscad-declarations.d.ts +1 -1
  14. package/dist/generated/openscad-declarations.js +1 -1
  15. package/dist/generated/scene-declarations.d.ts +1 -1
  16. package/dist/generated/scene-declarations.js +1 -1
  17. package/dist/native-executor.js +2 -2
  18. package/dist/openscad.d.ts +1 -2
  19. package/dist/openscad.js +1 -19
  20. package/dist/quickjs-executor.js +1 -1
  21. package/dist/repair-state.d.ts +2 -0
  22. package/dist/repair-state.js +15 -0
  23. package/dist/runtime.d.ts +1 -5
  24. package/dist/runtime.js +8 -14
  25. package/dist/scene-environment.js +1 -1
  26. package/dist/scene-health.d.ts +1 -2
  27. package/dist/scene-health.js +0 -17
  28. package/dist/scene-operations.d.ts +5 -5
  29. package/dist/scene-operations.js +24 -43
  30. package/dist/sdk-geometry-host.d.ts +2 -2
  31. package/dist/sdk-geometry-host.js +44 -61
  32. package/dist/sdk-openscad-host.js +63 -102
  33. package/dist/sdk-scene-health-host.d.ts +2 -1
  34. package/dist/sdk-scene-health-host.js +54 -4
  35. package/dist/sdk-scene-host.d.ts +20 -5
  36. package/dist/sdk-scene-host.js +34 -31
  37. package/dist/sdk-scene-screenshot.d.ts +2 -2
  38. package/dist/sdk-scene-screenshot.js +3 -16
  39. package/dist/server/scene-access.d.ts +4 -0
  40. package/dist/server/scene-access.js +40 -0
  41. package/dist/server/standalone.js +24 -19
  42. package/dist/womp-scene-api.d.ts +15 -0
  43. package/dist/womp-scene-api.js +18 -1
  44. package/package.json +139 -139
  45. package/dist/server/scene-initialization.d.ts +0 -3
  46. package/dist/server/scene-initialization.js +0 -44
@@ -1,8 +1,7 @@
1
1
  import { z } from "zod";
2
- import { getOpenScadGroundedPosition, MAX_PRINTABILITY_REPAIR_ATTEMPTS, requestStableOpenScadPrintability, toOpenScadCompileResponse, } from "./openscad.js";
3
- import { waitForOpenScadRenderer } from "./sdk-openscad-renderer.js";
2
+ import { getOpenScadGroundedPosition, MAX_PRINTABILITY_REPAIR_ATTEMPTS, requestTurnOpenScadPrintability, toOpenScadCompileResponse, } from "./openscad.js";
4
3
  import { createGeometryRepairTracker, createRepairTracker, } from "./repair-state.js";
5
- import { getSdkSceneAreaId, hasGeometryOverflow, readStableSdkSceneHealth, unverifiedSceneHealth, } from "./scene-health.js";
4
+ import { getSdkSceneAreaId, unverifiedSceneHealth, } from "./scene-health.js";
6
5
  const compileMetadata = z.object({
7
6
  error: z.string(),
8
7
  params: z.array(z.object({ name: z.string() })),
@@ -19,7 +18,6 @@ export function createSdkOpenScadHost(options) {
19
18
  const printabilityRepairs = createRepairTracker(options.repairs, "openscad.printability", MAX_PRINTABILITY_REPAIR_ATTEMPTS, (status) => status === "unavailable" ? undefined : status === "failed");
20
19
  const pendingTargets = new Set();
21
20
  const resolveTarget = async (api, objectId) => {
22
- const revision = await api.getSceneRevision();
23
21
  const node = objectId === 0 ? undefined : api.getNode(objectId);
24
22
  if (objectId === 0 || node?.kind === "union" || node?.kind === "group") {
25
23
  return {
@@ -30,18 +28,12 @@ export function createSdkOpenScadHost(options) {
30
28
  title: node?.name ?? "",
31
29
  source: null,
32
30
  params: {},
33
- revision,
34
31
  },
35
32
  };
36
33
  }
37
34
  if (node?.kind !== "openScad")
38
35
  throw new Error("The target must be an OpenSCAD part or an Area/Group.");
39
36
  const script = api.getOpenScadScript(node.scriptId);
40
- let rendererBounds;
41
- try {
42
- rendererBounds = await api.getNodeBoundingBox(objectId);
43
- }
44
- catch { }
45
37
  return {
46
38
  target: {
47
39
  kind: "part",
@@ -50,10 +42,8 @@ export function createSdkOpenScadHost(options) {
50
42
  source: script.source,
51
43
  title: script.name || node.name || "OpenSCAD part",
52
44
  params: structuredClone(node.params ?? {}),
53
- revision,
54
45
  },
55
46
  repairKey: `script:${node.scriptId}`,
56
- rendererBounds,
57
47
  };
58
48
  };
59
49
  const write = async (source, targetId, creating, invocation) => {
@@ -68,8 +58,6 @@ export function createSdkOpenScadHost(options) {
68
58
  let operationId = "";
69
59
  let operationRepairKey = resolved.repairKey;
70
60
  try {
71
- if (target.revision !== source.expectedRevision)
72
- throw new Error("The scene changed. Read it again before editing.");
73
61
  if (target.kind !== (creating ? "container" : "part"))
74
62
  throw new Error(creating
75
63
  ? "Creation requires a parent container ID."
@@ -85,63 +73,60 @@ export function createSdkOpenScadHost(options) {
85
73
  }
86
74
  if (creating && options.canGrow?.() === false)
87
75
  throw new Error("OpenSCAD creation is disabled because scene memory is low.");
88
- const params = z
89
- .record(z.string(), z.json())
90
- .parse({ ...target.params, ...source.params });
91
- invocation.reportStatus("running", "Compiling the parametric part...");
92
- let rawValidation;
93
- try {
94
- rawValidation = await editor.read(invocation, (api) => api.validateOpenScad(source.source, params));
95
- }
96
- catch {
97
- invocation.signal.throwIfAborted();
98
- }
99
- const response = toOpenScadCompileResponse(rawValidation);
100
- const metadataAvailable = compileMetadata.safeParse(rawValidation).success;
101
- const validation = response && (response.error || metadataAvailable)
102
- ? {
103
- status: response.error ? "invalid" : "valid",
104
- error: response.error || undefined,
105
- warnings: response.warnings,
106
- }
107
- : {
108
- status: "unavailable",
109
- warnings: [
110
- "OpenSCAD validation is unavailable; compilation and parameter validity could not be confirmed.",
111
- ],
112
- };
113
- if (validation.status === "invalid" ||
114
- (creating &&
115
- (validation.status !== "valid" ||
116
- response?.boundingBox === undefined))) {
117
- if (creating && response?.boundingBox === undefined)
118
- validation.warnings.push("The part was not added because validated placement bounds are unavailable.");
119
- return {
120
- status: "rejected",
121
- objectId,
122
- revision: target.revision,
123
- validation,
124
- repairAttempts: 0,
125
- repairLimit: MAX_PRINTABILITY_REPAIR_ATTEMPTS,
126
- };
127
- }
128
- const validNames = new Set(response && metadataAvailable
129
- ? response.params.map((param) => param.name)
130
- : Object.keys(params));
131
- const nextParams = Object.fromEntries(Object.entries(params).filter(([name]) => validNames.has(name)));
132
76
  const receiptTarget = {
133
77
  parentId: target.parentId,
134
78
  };
135
79
  if (objectId !== undefined)
136
80
  receiptTarget.objectId = objectId;
137
81
  let affectedObjectIds = [];
138
- let sceneHealth;
139
- const applied = await editor.edit({
82
+ let affectedAreaIds = [];
83
+ const applied = await editor.access({
140
84
  capability: "openscad",
141
85
  operation: creating ? "create" : "update",
142
86
  target: () => receiptTarget,
143
- }, async (_api, draft, id) => {
87
+ }, async (api, draft, id) => {
144
88
  operationId = id;
89
+ const current = draft.getNode(targetId);
90
+ if (creating === false && (current.kind !== "openScad" || `script:${current.scriptId}` !== resolved.repairKey))
91
+ throw new Error("The OpenSCAD target changed. Read it again.");
92
+ const currentParams = current.kind === "openScad" ? current.params : {};
93
+ const params = z
94
+ .record(z.string(), z.json())
95
+ .parse({ ...currentParams, ...source.params });
96
+ invocation.reportStatus("running", "Compiling the parametric part...");
97
+ let rawValidation;
98
+ try {
99
+ rawValidation = await api.validateOpenScad(source.source, params);
100
+ }
101
+ catch {
102
+ invocation.signal.throwIfAborted();
103
+ }
104
+ const response = toOpenScadCompileResponse(rawValidation);
105
+ const metadataAvailable = compileMetadata.safeParse(rawValidation).success;
106
+ const validation = response && (response.error || metadataAvailable)
107
+ ? {
108
+ status: response.error ? "invalid" : "valid",
109
+ ...(response.error ? { error: response.error } : {}),
110
+ warnings: response.warnings,
111
+ }
112
+ : {
113
+ status: "unavailable",
114
+ warnings: [
115
+ "OpenSCAD validation is unavailable; compilation and parameter validity could not be confirmed.",
116
+ ],
117
+ };
118
+ if (validation.status === "invalid" ||
119
+ (creating &&
120
+ (validation.status !== "valid" ||
121
+ response?.boundingBox === undefined))) {
122
+ if (creating && response?.boundingBox === undefined)
123
+ validation.warnings.push("The part was not added because validated placement bounds are unavailable.");
124
+ return { rejected: validation };
125
+ }
126
+ const validNames = new Set(response && metadataAvailable
127
+ ? response.params.map((param) => param.name)
128
+ : Object.keys(params));
129
+ const nextParams = Object.fromEntries(Object.entries(params).filter(([name]) => validNames.has(name)));
145
130
  if (creating === false) {
146
131
  if (resolved.repairKey === undefined)
147
132
  throw new Error("The OpenSCAD repair target is unavailable.");
@@ -158,7 +143,8 @@ export function createSdkOpenScadHost(options) {
158
143
  });
159
144
  draft.setNodeName(targetId, source.title);
160
145
  draft.setOpenScadScript(targetId, node.scriptId, nextParams);
161
- return { objectId: targetId, repairKey: resolved.repairKey };
146
+ affectedAreaIds = [...new Set(affectedObjectIds.map(id => Number(getSdkSceneAreaId(draft, id))))];
147
+ return { objectId: targetId, repairKey: resolved.repairKey, validation };
162
148
  }
163
149
  if (options.canGrow?.() === false)
164
150
  throw new Error("OpenSCAD creation is disabled because scene memory is low.");
@@ -186,69 +172,46 @@ export function createSdkOpenScadHost(options) {
186
172
  objectId = node.id;
187
173
  affectedObjectIds = [node.id];
188
174
  Object.assign(receiptTarget, { objectId: node.id, parentId });
189
- return { objectId: node.id, repairKey: operationRepairKey };
190
- }, source.expectedRevision, invocation, async (operation, api) => {
191
- if (objectId === undefined)
192
- return;
193
- const areaIds = [
194
- ...new Set(affectedObjectIds.map((id) => getSdkSceneAreaId(api, id))),
195
- ];
196
- try {
197
- const rendererReady = response?.boundingBox === undefined ||
198
- (creating === false && resolved.rendererBounds === undefined)
199
- ? false
200
- : await waitForOpenScadRenderer({
201
- api,
202
- objectId,
203
- expectedBounds: response.boundingBox,
204
- baselineBounds: resolved.rendererBounds,
205
- signal: invocation.signal,
206
- });
207
- sceneHealth = rendererReady
208
- ? await readStableSdkSceneHealth(api, areaIds, invocation.signal)
209
- : unverifiedSceneHealth(areaIds, "The renderer did not confirm the committed OpenSCAD geometry before health checks.");
210
- }
211
- catch (error) {
212
- sceneHealth = unverifiedSceneHealth(areaIds, `OpenSCAD renderer checks stopped after the edit committed: ${String(error)}`);
213
- }
214
- options.onHealth?.(sceneHealth);
175
+ affectedAreaIds = [Number(getSdkSceneAreaId(draft, node.id))];
176
+ return { objectId: node.id, repairKey: operationRepairKey, validation };
177
+ }, invocation, async (operation) => {
215
178
  if (operationRepairKey === undefined)
216
179
  return;
217
- const recorded = await geometryRepairs.record(operationRepairKey, operation.id, sceneHealth);
180
+ const recorded = await geometryRepairs.record(operationRepairKey, operation.id, unverifiedSceneHealth([], "Health was not requested."));
218
181
  return {
219
- sceneHealth,
220
182
  affectedObjectIds,
221
183
  geometryRepairAttempts: recorded.repairAttempts,
222
184
  geometryRepairLimit: recorded.repairLimit,
223
185
  };
224
186
  });
187
+ if (applied.value.rejected)
188
+ return {
189
+ status: "rejected", objectId, validation: applied.value.rejected,
190
+ repairAttempts: 0, repairLimit: MAX_PRINTABILITY_REPAIR_ATTEMPTS,
191
+ };
192
+ const { validation } = applied.value;
225
193
  objectId = applied.value.objectId;
226
194
  try {
227
195
  await options.onSelect?.(objectId);
228
196
  }
229
197
  catch { }
230
- const revision = applied.operation.resultRevision ?? source.expectedRevision;
231
- if (sceneHealth?.status === "unverified" ||
232
- (sceneHealth !== undefined && hasGeometryOverflow(sceneHealth))) {
233
- await printabilityRepairs.refund(applied.value.repairKey, operationId);
198
+ // Geometry repairs do not consume the independent printability budget.
199
+ // Explicit health must resolve the known geometry failure first.
200
+ if (repairingGeometry) {
234
201
  return {
235
202
  status: "applied",
236
203
  objectId,
237
- revision,
238
204
  validation,
239
205
  repairAttempts: 0,
240
206
  repairLimit: MAX_PRINTABILITY_REPAIR_ATTEMPTS,
241
207
  };
242
208
  }
243
209
  invocation.reportStatus("running", "Checking wall thickness...");
244
- const printability = await editor.read(invocation, (api, beginSettlement) => (options.requestPrintability ?? requestStableOpenScadPrintability)({
210
+ const printability = await editor.withConnection(invocation, (api, beginSettlement) => (options.requestPrintability ?? requestTurnOpenScadPrintability)({
245
211
  api,
246
- expectedRevision: revision,
247
212
  signal: invocation.signal,
248
213
  onSubmissionStarted: beginSettlement,
249
- objectIds: [
250
- ...new Set(affectedObjectIds.map((id) => Number(getSdkSceneAreaId(api, id)))),
251
- ],
214
+ objectIds: affectedAreaIds,
252
215
  }));
253
216
  invocation.signal.throwIfAborted();
254
217
  if (printability.reason === "context_busy") {
@@ -256,7 +219,6 @@ export function createSdkOpenScadHost(options) {
256
219
  return {
257
220
  status: "applied",
258
221
  objectId,
259
- revision,
260
222
  validation,
261
223
  printability,
262
224
  repairAttempts: refunded.repairAttempts,
@@ -267,7 +229,6 @@ export function createSdkOpenScadHost(options) {
267
229
  return {
268
230
  status: "applied",
269
231
  objectId,
270
- revision,
271
232
  validation,
272
233
  printability,
273
234
  repairAttempts: recorded.repairAttempts,
@@ -1,4 +1,5 @@
1
1
  import type { SceneHealthHost } from "./capabilities.js";
2
2
  import { type SdkSceneEditor } from "./sdk-scene-host.js";
3
+ import { type RepairStateStore } from "./repair-state.js";
3
4
  import type { SceneHealth } from "./scene-health.js";
4
- export declare function createSdkSceneHealthHost(editor: SdkSceneEditor, onHealth?: (health: SceneHealth) => void): SceneHealthHost;
5
+ export declare function createSdkSceneHealthHost(editor: SdkSceneEditor, onHealth?: (health: SceneHealth) => void, repairs?: RepairStateStore): SceneHealthHost;
@@ -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. */
5
- export declare function captureSdkSceneScreenshot(api: KakapoAPI, input: SceneScreenshotOptions): Promise<ExecutionScreenshot>;
4
+ /** Capture the current native viewport without fetching the scene or changing its camera. */
5
+ export declare function captureSdkSceneScreenshot(api: KakapoAPI, _input: SceneScreenshotOptions): Promise<ExecutionScreenshot>;
@@ -1,17 +1,4 @@
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. */
3
- export async function captureSdkSceneScreenshot(api, input) {
4
- if (!input.camera)
5
- return api.captureScreenshot({ timeoutMs: 30_000 });
6
- const scene = await api.refreshScene();
7
- const units = sceneUnitMultiplier(scene.properties.unit_multiplier);
8
- const camera = {
9
- ...input.camera,
10
- position: toEnginePosition(input.camera.position, units),
11
- target: toEnginePosition(input.camera.target, units),
12
- };
13
- if (camera.orthographicWidth !== undefined)
14
- camera.orthographicWidth *= INTERNAL_UNITS_PER_MM / units;
15
- await api.setCamera(camera);
16
- return api.captureScreenshot({ camera, timeoutMs: 30_000 });
1
+ /** Capture the current native viewport without fetching the scene or changing its camera. */
2
+ export async function captureSdkSceneScreenshot(api, _input) {
3
+ return api.captureScreenshot({ timeoutMs: 30_000 });
17
4
  }
@@ -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
+ }