@womp/spark-agent 4.2.1 → 4.3.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 (36) hide show
  1. package/.env.example +6 -6
  2. package/README.md +457 -436
  3. package/dist/agent-run.d.ts +1 -1
  4. package/dist/capabilities/flow.js +3 -3
  5. package/dist/capabilities/geometry.js +14 -14
  6. package/dist/capabilities/openscad-instructions.d.ts +1 -1
  7. package/dist/capabilities/openscad-instructions.js +2 -1
  8. package/dist/capabilities/operations.js +33 -32
  9. package/dist/capabilities/scene.js +74 -74
  10. package/dist/client.js +8 -0
  11. package/dist/geometry/schemas/backend-schema.json +3393 -3393
  12. package/dist/geometry/schemas/flowir-dialect.json +1023 -1023
  13. package/dist/geometry.js +6 -6
  14. package/dist/models.d.ts +12 -5
  15. package/dist/models.js +16 -7
  16. package/dist/pricing.js +15 -0
  17. package/dist/prompt.d.ts +1 -1
  18. package/dist/prompt.js +65 -70
  19. package/dist/server/express.js +9 -1
  20. package/dist/server/fixtures/materials/matte-blue.json +14 -14
  21. package/dist/server/fixtures/materials/polished-copper.json +14 -14
  22. package/dist/server/index.d.ts +2 -2
  23. package/dist/server/index.js +1 -1
  24. package/dist/server/message-fold.d.ts +59 -0
  25. package/dist/server/message-fold.js +345 -0
  26. package/dist/server/scene-initialization.d.ts +3 -0
  27. package/dist/server/scene-initialization.js +44 -0
  28. package/dist/server/service.d.ts +82 -5
  29. package/dist/server/service.js +294 -63
  30. package/dist/server/sqlite-store.d.ts +1 -0
  31. package/dist/server/sqlite-store.js +40 -37
  32. package/dist/server/standalone.js +3 -0
  33. package/dist/server/store.d.ts +1 -0
  34. package/dist/server/store.js +7 -0
  35. package/dist/server/types.d.ts +7 -0
  36. package/package.json +139 -139
@@ -1,5 +1,6 @@
1
1
  import { sparkErrorDiagnostics } from "../error-diagnostics.js";
2
- import { readUIMessageStream, validateUIMessages } from "ai";
2
+ import { validateUIMessages } from "ai";
3
+ import { foldAllChunks } from "./message-fold.js";
3
4
  import { sparkDataPartSchemas, sparkMessageMetadataSchema, } from "../ui.js";
4
5
  const TERMINAL_STATUSES = new Set([
5
6
  "completed",
@@ -7,6 +8,23 @@ const TERMINAL_STATUSES = new Set([
7
8
  "cancelled",
8
9
  "incomplete",
9
10
  ]);
11
+ /** Why a run ended when its progress could not be stored. */
12
+ export const RUN_NOT_SAVED_ERROR = "This run was stopped because its progress could not be saved.";
13
+ /** Why a run ended that no execution in this process was running any more. */
14
+ export const RUN_INTERRUPTED_ERROR = "This run stopped unexpectedly and could not be finished.";
15
+ /** How long to wait before each further attempt to store a run's ending. About 5 minutes in all. */
16
+ export const DEFAULT_ENDING_RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 15_000, 30_000, 60_000, 60_000, 60_000, 60_000];
17
+ /** Waits without keeping the process alive. */
18
+ function sleep(ms) {
19
+ return new Promise((resolve) => {
20
+ const timer = setTimeout(resolve, ms);
21
+ if (typeof timer === "object" && timer !== null && "unref" in timer)
22
+ timer.unref();
23
+ });
24
+ }
25
+ function runNotSaved(cause) {
26
+ return new Error(RUN_NOT_SAVED_ERROR, { cause });
27
+ }
10
28
  export class SparkServiceError extends Error {
11
29
  statusCode;
12
30
  constructor(statusCode, message) {
@@ -31,15 +49,6 @@ function titleFor(message) {
31
49
  return "New build";
32
50
  return text.length > 44 ? `${text.slice(0, 44)}…` : text;
33
51
  }
34
- function chunkStream(chunks) {
35
- return new ReadableStream({
36
- start(controller) {
37
- for (const chunk of chunks)
38
- controller.enqueue(chunk);
39
- controller.close();
40
- },
41
- });
42
- }
43
52
  function normalizeUserMessage(message) {
44
53
  if (message.role !== "user") {
45
54
  throw new SparkServiceError(400, "A new chat message must have the user role.");
@@ -70,9 +79,19 @@ export class SparkAgentService {
70
79
  controllers = new Map();
71
80
  listeners = new Map();
72
81
  executions = new Map();
82
+ /** Runs this process started and has not finished ending. */
83
+ live = new Set();
84
+ /** Runs whose execution is over but whose ending is still being stored. */
85
+ ending = new Map();
86
+ endOrphanedRuns;
87
+ /** Per conversation: the tail of its read-modify-write sections in this process. */
88
+ conversationTurns = new Map();
89
+ endingRetryDelaysMs;
73
90
  constructor(options) {
74
91
  this.store = options.store;
75
92
  this.executeRun = options.executeRun;
93
+ this.endOrphanedRuns = options.endOrphanedRuns ?? false;
94
+ this.endingRetryDelaysMs = options.endingRetryDelaysMs ?? DEFAULT_ENDING_RETRY_DELAYS_MS;
76
95
  }
77
96
  async createConversation(ownerId, input) {
78
97
  const createdAt = now();
@@ -123,7 +142,16 @@ export class SparkAgentService {
123
142
  return undefined;
124
143
  return run;
125
144
  }
126
- /** The host must establish exclusive ownership before recovering abandoned runs. */
145
+ /**
146
+ * The host must establish exclusive ownership before recovering abandoned runs.
147
+ *
148
+ * The ending this writes spans event-loop turns: the run's message is folded
149
+ * a step at a time and yields as it goes (`finishRun`). The recovered run is
150
+ * not one this process is running, so nothing here keeps a submit to its
151
+ * conversation out meanwhile; `finishRun` then saves the conversation as it
152
+ * read it, over anything that submit wrote. Recover before accepting
153
+ * requests, as the standalone server does.
154
+ */
127
155
  async recoverInterruptedRun(runId) {
128
156
  if (this.controllers.has(runId))
129
157
  throw new Error("Cannot recover a live run");
@@ -142,12 +170,18 @@ export class SparkAgentService {
142
170
  await Promise.all([...this.executions.values()]);
143
171
  }
144
172
  async submitMessage(input) {
173
+ return this.inConversationTurn(input.conversationId, () => this.submitInTurn(input));
174
+ }
175
+ async submitInTurn(input) {
145
176
  const conversation = await this.getConversation(input.ownerId, input.conversationId);
146
177
  const activeRun = conversation.activeRunId === undefined
147
178
  ? undefined
148
179
  : await this.store.getRun(conversation.activeRunId);
149
180
  if (activeRun !== undefined && TERMINAL_STATUSES.has(activeRun.status) === false) {
150
- throw new SparkServiceError(409, "This conversation already has an active run.");
181
+ if (!(await this.endIfNotRunning(activeRun, true)))
182
+ throw new SparkServiceError(409, "This conversation already has an active run.");
183
+ if (conversation.activeRunId === activeRun.id)
184
+ conversation.activeRunId = undefined;
151
185
  }
152
186
  let inputMessage;
153
187
  if (input.trigger === "regenerate-message") {
@@ -193,25 +227,47 @@ export class SparkAgentService {
193
227
  };
194
228
  conversation.activeRunId = run.id;
195
229
  conversation.updatedAt = createdAt;
196
- await this.store.saveConversation(conversation);
197
- await this.store.createRun(run);
198
- await this.appendChunk(run, {
199
- type: "start",
200
- messageId: run.assistantMessageId,
201
- messageMetadata: {
202
- createdAt,
203
- runId: run.id,
204
- runStatus: "queued",
205
- modelId: run.settings.modelId,
206
- },
207
- });
208
- await this.appendStatus(run, "queued");
230
+ this.live.add(run.id);
231
+ try {
232
+ await this.store.saveConversation(conversation);
233
+ await this.store.createRun(run);
234
+ }
235
+ catch (error) {
236
+ this.live.delete(run.id);
237
+ throw error;
238
+ }
239
+ try {
240
+ await this.appendChunk(run, {
241
+ type: "start",
242
+ messageId: run.assistantMessageId,
243
+ messageMetadata: {
244
+ createdAt,
245
+ runId: run.id,
246
+ runStatus: "queued",
247
+ modelId: run.settings.modelId,
248
+ },
249
+ });
250
+ await this.appendStatus(run, "queued");
251
+ }
252
+ catch (error) {
253
+ // The run exists and the conversation names it, so it is ended here
254
+ // rather than left `queued` with the conversation refusing every later
255
+ // message.
256
+ const failure = runNotSaved(error);
257
+ const diagnostics = sparkErrorDiagnostics(failure);
258
+ await this.endWithoutHistory(run, { status: "failed", error: diagnostics.message, errorDiagnostics: diagnostics }, error, true);
259
+ throw failure;
260
+ }
209
261
  const controller = new AbortController();
210
262
  this.controllers.set(run.id, controller);
211
263
  const execution = this.runInBackground(run, conversation, controller);
212
264
  this.executions.set(run.id, execution);
213
265
  void execution
214
- .finally(() => this.executions.delete(run.id))
266
+ .finally(() => {
267
+ this.executions.delete(run.id);
268
+ if (!this.ending.has(run.id))
269
+ this.live.delete(run.id);
270
+ })
215
271
  .catch((error) => {
216
272
  console.error("Spark run persistence failed", error);
217
273
  });
@@ -223,8 +279,11 @@ export class SparkAgentService {
223
279
  return run;
224
280
  const controller = this.controllers.get(run.id);
225
281
  const execution = this.executions.get(run.id);
226
- if (!controller || !execution)
282
+ if (!controller || !execution) {
283
+ if (await this.endIfNotRunning(run))
284
+ return this.getRun(ownerId, runId);
227
285
  throw new Error("The run's executor is unavailable; cancellation could not be confirmed.");
286
+ }
228
287
  controller.abort(new Error("Run cancelled by the user."));
229
288
  // Drain receipts and pending host work before publishing a terminal state.
230
289
  await execution;
@@ -287,21 +346,26 @@ export class SparkAgentService {
287
346
  }
288
347
  }
289
348
  async runInBackground(run, conversation, controller) {
290
- const startedAt = now();
291
- run = {
292
- ...run,
293
- status: "running",
294
- startedAt,
295
- updatedAt: startedAt,
296
- };
297
- await this.store.saveRun(run);
298
- const runningChunk = {
299
- type: "data-run-status",
300
- id: `run-status-${run.id}`,
301
- data: { runId: run.id, status: "running" },
302
- };
303
- await this.appendChunk(run, runningChunk);
349
+ // Aborts the execution when its progress cannot be stored. Unlike
350
+ // `controller`, it does not mean the user cancelled.
351
+ const unsaved = new AbortController();
352
+ // A terminal status already in the run's chunks; a later failure keeps it.
353
+ let written;
304
354
  try {
355
+ const startedAt = now();
356
+ run = {
357
+ ...run,
358
+ status: "running",
359
+ startedAt,
360
+ updatedAt: startedAt,
361
+ };
362
+ await this.store.saveRun(run);
363
+ const runningChunk = {
364
+ type: "data-run-status",
365
+ id: `run-status-${run.id}`,
366
+ data: { runId: run.id, status: "running" },
367
+ };
368
+ await this.appendChunk(run, runningChunk);
305
369
  const execution = await this.executeRun({
306
370
  ownerId: conversation.ownerId,
307
371
  conversationId: conversation.id,
@@ -309,13 +373,33 @@ export class SparkAgentService {
309
373
  assistantMessageId: run.assistantMessageId,
310
374
  uiMessages: conversation.messages,
311
375
  settings: run.settings,
312
- signal: controller.signal,
376
+ signal: AbortSignal.any([controller.signal, unsaved.signal]),
313
377
  });
378
+ // A chunk that cannot be stored aborts the execution, and the stream is
379
+ // still read to its end: an execution settles its result only once its
380
+ // stream has been read, so a reader that stopped here would leave the
381
+ // run waiting on its result for good.
382
+ let persistError;
314
383
  const consume = (async () => {
315
- for await (const chunk of execution.stream) {
316
- if (chunk.type === "start")
317
- continue;
318
- await this.appendChunk(run, chunk);
384
+ const reader = execution.stream.getReader();
385
+ try {
386
+ for (;;) {
387
+ const next = await reader.read();
388
+ if (next.done)
389
+ return;
390
+ if (next.value.type === "start" || persistError !== undefined)
391
+ continue;
392
+ try {
393
+ await this.appendChunk(run, next.value);
394
+ }
395
+ catch (error) {
396
+ persistError = error;
397
+ unsaved.abort(runNotSaved(error));
398
+ }
399
+ }
400
+ }
401
+ finally {
402
+ reader.releaseLock();
319
403
  }
320
404
  })().then(() => undefined, (error) => error);
321
405
  let executionError;
@@ -327,27 +411,42 @@ export class SparkAgentService {
327
411
  executionError = error;
328
412
  }
329
413
  const streamError = await consume;
414
+ if (persistError !== undefined)
415
+ throw runNotSaved(persistError);
330
416
  if (executionError !== undefined)
331
417
  throw executionError;
332
418
  if (streamError !== undefined)
333
419
  throw streamError;
334
- if (controller.signal.aborted) {
335
- await this.appendStatus(run, "cancelled");
336
- await this.finishRun(run, conversation, "cancelled");
337
- return;
338
- }
339
- const status = result?.status ?? "incomplete";
420
+ const status = controller.signal.aborted ? "cancelled" : (result?.status ?? "incomplete");
340
421
  await this.appendStatus(run, status);
422
+ written = status;
341
423
  await this.finishRun(run, conversation, status);
342
424
  }
343
425
  catch (error) {
344
- if (controller.signal.aborted) {
426
+ if (written !== undefined)
427
+ await this.endWithoutHistory(run, { status: written }, error);
428
+ else
429
+ await this.endAfterError(run, conversation, controller, error);
430
+ }
431
+ finally {
432
+ this.controllers.delete(run.id);
433
+ }
434
+ }
435
+ /** Ends a run whose execution, or whose own bookkeeping, failed. */
436
+ async endAfterError(run, conversation, controller, error) {
437
+ if (controller.signal.aborted) {
438
+ try {
345
439
  await this.appendStatus(run, "cancelled");
346
440
  await this.finishRun(run, conversation, "cancelled");
347
- return;
348
441
  }
349
- const diagnostics = sparkErrorDiagnostics(error);
350
- const message = diagnostics.message;
442
+ catch (endingError) {
443
+ await this.endWithoutHistory(run, { status: "cancelled" }, endingError);
444
+ }
445
+ return;
446
+ }
447
+ const diagnostics = sparkErrorDiagnostics(error);
448
+ const message = diagnostics.message;
449
+ try {
351
450
  const failedChunk = {
352
451
  type: "data-run-status",
353
452
  id: `run-status-${run.id}`,
@@ -357,10 +456,143 @@ export class SparkAgentService {
357
456
  await this.appendChunk(run, failedChunk);
358
457
  await this.finishRun(run, conversation, "failed", message, diagnostics);
359
458
  }
459
+ catch (endingError) {
460
+ await this.endWithoutHistory(run, { status: "failed", error: message, errorDiagnostics: diagnostics }, endingError);
461
+ }
462
+ }
463
+ /**
464
+ * The last resort when a run's ending could not be written in full: the run
465
+ * row gets its terminal status and the conversation lets go of the run, so
466
+ * it is not left unfinished. Its stored chunks may lack the ending. A store
467
+ * that is still failing is retried in the background with backoff
468
+ * (`endingRetryDelaysMs`); until then, a message or a Stop for the
469
+ * conversation ends the run at once (`endIfNotRunning`).
470
+ */
471
+ async endWithoutHistory(run, ending, cause, inTurn = false) {
472
+ console.error("Spark run ending could not be stored in full", cause);
473
+ this.ending.set(run.id, ending);
474
+ if (await this.tryStoreEnding(run, ending, inTurn)) {
475
+ this.ending.delete(run.id);
476
+ return;
477
+ }
478
+ void (async () => {
479
+ for (const delay of this.endingRetryDelaysMs) {
480
+ await sleep(delay);
481
+ // Ended meanwhile by a message or a Stop (`endIfNotRunning`).
482
+ if (!this.ending.has(run.id))
483
+ break;
484
+ if (await this.tryStoreEnding(run, ending))
485
+ break;
486
+ }
487
+ this.ending.delete(run.id);
488
+ if (!this.executions.has(run.id))
489
+ this.live.delete(run.id);
490
+ })();
491
+ }
492
+ /** `storeEnding`, answering whether it was stored rather than throwing. */
493
+ async tryStoreEnding(run, ending, inTurn = false) {
494
+ try {
495
+ await this.storeEnding(run.id, ending, inTurn);
496
+ return true;
497
+ }
498
+ catch (error) {
499
+ console.error("Spark run ending could not be stored", error);
500
+ return false;
501
+ }
502
+ }
503
+ /**
504
+ * Runs `action` after every earlier section for the conversation in this
505
+ * process has finished, so a read-modify-write of it (a submit, a released
506
+ * run) never saves back a copy another one has since replaced.
507
+ */
508
+ async inConversationTurn(conversationId, action) {
509
+ const previous = this.conversationTurns.get(conversationId) ?? Promise.resolve();
510
+ let done = () => undefined;
511
+ const turn = new Promise((resolve) => {
512
+ done = resolve;
513
+ });
514
+ const tail = previous.then(() => turn);
515
+ this.conversationTurns.set(conversationId, tail);
516
+ await previous;
517
+ try {
518
+ return await action();
519
+ }
360
520
  finally {
361
- this.controllers.delete(run.id);
521
+ done();
522
+ if (this.conversationTurns.get(conversationId) === tail)
523
+ this.conversationTurns.delete(conversationId);
524
+ }
525
+ }
526
+ /**
527
+ * Writes a run's terminal status unless the stored run has already ended,
528
+ * then clears the conversation's active run while it still names the run.
529
+ * Read back first, so a late write never replaces a newer ending or a
530
+ * newer active run.
531
+ */
532
+ async storeEnding(runId, ending, inTurn = false) {
533
+ const stored = await this.store.getRun(runId);
534
+ if (stored === undefined)
535
+ return;
536
+ const completedAt = now();
537
+ if (!TERMINAL_STATUSES.has(stored.status)) {
538
+ await this.store.saveRun({
539
+ ...stored,
540
+ status: ending.status,
541
+ ...(ending.error === undefined ? {} : { error: ending.error }),
542
+ ...(ending.errorDiagnostics === undefined ? {} : { errorDiagnostics: ending.errorDiagnostics }),
543
+ updatedAt: completedAt,
544
+ completedAt,
545
+ });
546
+ }
547
+ await this.releaseRun(stored.conversationId, runId, completedAt, inTurn);
548
+ this.ending.delete(runId);
549
+ this.wakeRun(runId);
550
+ }
551
+ /** Clears the conversation's active run while it is still `runId`, and nothing else. */
552
+ async releaseRun(conversationId, runId, at, inTurn) {
553
+ if (this.store.releaseActiveRun !== undefined) {
554
+ await this.store.releaseActiveRun(conversationId, runId, at);
555
+ return;
556
+ }
557
+ const release = async () => {
558
+ const conversation = await this.store.getConversation(conversationId);
559
+ if (conversation === undefined || conversation.activeRunId !== runId)
560
+ return;
561
+ conversation.activeRunId = undefined;
562
+ conversation.updatedAt = at;
563
+ await this.store.saveConversation(conversation);
564
+ };
565
+ if (inTurn)
566
+ await release();
567
+ else
568
+ await this.inConversationTurn(conversationId, release);
569
+ }
570
+ /**
571
+ * Ends an unfinished run that is not being executed: one whose ending this
572
+ * process is still retrying, or, with `endOrphanedRuns`, one no execution in
573
+ * this process is running at all (its process stopped). Answers whether it
574
+ * was ended; throws when the store is still failing.
575
+ */
576
+ async endIfNotRunning(run, inTurn = false) {
577
+ const pending = this.ending.get(run.id);
578
+ if (pending !== undefined) {
579
+ await this.storeEnding(run.id, pending, inTurn);
580
+ return true;
362
581
  }
582
+ if (!this.endOrphanedRuns || this.live.has(run.id))
583
+ return false;
584
+ const diagnostics = sparkErrorDiagnostics(RUN_INTERRUPTED_ERROR);
585
+ await this.storeEnding(run.id, { status: "failed", error: diagnostics.message, errorDiagnostics: diagnostics }, inTurn);
586
+ return true;
363
587
  }
588
+ /**
589
+ * Writes a run's ending: its message, rebuilt from every stored chunk, onto
590
+ * the conversation, then the run's terminal state. The rebuild is folded a
591
+ * step at a time and gives the event loop back as it goes (./message-fold.ts),
592
+ * so the ending spans event-loop turns; for a run this process is running, it
593
+ * is still in `live` throughout, so a submit to its conversation is refused
594
+ * until it has ended.
595
+ */
364
596
  async finishRun(run, conversation, status, error, errorDiagnostics) {
365
597
  const completedAt = now();
366
598
  const storedChunks = await this.store.listRunChunks(run.id, 0);
@@ -392,14 +624,13 @@ export class SparkAgentService {
392
624
  this.wakeRun(run.id);
393
625
  }
394
626
  async materializeMessage(run, chunks, status, completedAt, error, errorDiagnostics) {
627
+ // What a full `readUIMessageStream` replay of the chunks builds, folded a
628
+ // step at a time and yielding (./message-fold.ts): one pass over a long
629
+ // run copies the whole message per chunk and held the event loop for
630
+ // seconds as the run ended.
395
631
  let message;
396
632
  try {
397
- for await (const current of readUIMessageStream({
398
- stream: chunkStream(chunks),
399
- terminateOnError: false,
400
- })) {
401
- message = current;
402
- }
633
+ message = await foldAllChunks(chunks);
403
634
  }
404
635
  catch {
405
636
  return undefined;
@@ -21,6 +21,7 @@ export declare class SqliteSparkStore implements SparkAgentStore, SparkArtifactS
21
21
  getConversation(id: string): Promise<SparkConversation | undefined>;
22
22
  listConversations(ownerId: string): Promise<SparkConversationSummary[]>;
23
23
  saveConversation(value: SparkConversation): Promise<void>;
24
+ releaseActiveRun(conversationId: string, runId: string, updatedAt: string): Promise<void>;
24
25
  saveConversationSettings(id: string, settings: SparkRunSettings): Promise<void>;
25
26
  deleteConversation(id: string): Promise<boolean>;
26
27
  createRun(value: SparkRun): Promise<void>;
@@ -17,36 +17,36 @@ export class SqliteSparkStore {
17
17
  this.database.close();
18
18
  throw new Error(`Unsupported Spark database version: ${version}`);
19
19
  }
20
- this.database.exec(`
21
- PRAGMA busy_timeout = 5000;
22
- PRAGMA journal_mode = WAL;
23
- PRAGMA synchronous = FULL;
24
- PRAGMA foreign_keys = ON;
25
- CREATE TABLE IF NOT EXISTS conversations (
26
- id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, updated_at TEXT NOT NULL, data TEXT NOT NULL
27
- );
28
- CREATE INDEX IF NOT EXISTS conversation_owner ON conversations(owner_id, updated_at);
29
- CREATE TABLE IF NOT EXISTS runs (
30
- id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, data TEXT NOT NULL
31
- );
32
- CREATE TABLE IF NOT EXISTS chunks (
33
- run_id TEXT NOT NULL REFERENCES runs(id), sequence INTEGER NOT NULL, data TEXT NOT NULL,
34
- PRIMARY KEY (run_id, sequence)
35
- );
36
- CREATE TABLE IF NOT EXISTS artifacts (
37
- id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, metadata TEXT NOT NULL, bytes BLOB NOT NULL
38
- );
39
- CREATE TABLE IF NOT EXISTS execution_owner (
40
- singleton INTEGER PRIMARY KEY CHECK (singleton = 1), pid INTEGER NOT NULL, token TEXT NOT NULL
41
- );
42
- CREATE TABLE IF NOT EXISTS scene_operations (
43
- target_key TEXT NOT NULL, id TEXT NOT NULL, revision INTEGER NOT NULL, data TEXT NOT NULL,
44
- PRIMARY KEY (target_key, id)
45
- );
46
- CREATE TABLE IF NOT EXISTS repair_states (
47
- target_key TEXT PRIMARY KEY, revision INTEGER NOT NULL, data TEXT NOT NULL
48
- );
49
- PRAGMA user_version = 4;
20
+ this.database.exec(`
21
+ PRAGMA busy_timeout = 5000;
22
+ PRAGMA journal_mode = WAL;
23
+ PRAGMA synchronous = FULL;
24
+ PRAGMA foreign_keys = ON;
25
+ CREATE TABLE IF NOT EXISTS conversations (
26
+ id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, updated_at TEXT NOT NULL, data TEXT NOT NULL
27
+ );
28
+ CREATE INDEX IF NOT EXISTS conversation_owner ON conversations(owner_id, updated_at);
29
+ CREATE TABLE IF NOT EXISTS runs (
30
+ id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, data TEXT NOT NULL
31
+ );
32
+ CREATE TABLE IF NOT EXISTS chunks (
33
+ run_id TEXT NOT NULL REFERENCES runs(id), sequence INTEGER NOT NULL, data TEXT NOT NULL,
34
+ PRIMARY KEY (run_id, sequence)
35
+ );
36
+ CREATE TABLE IF NOT EXISTS artifacts (
37
+ id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, metadata TEXT NOT NULL, bytes BLOB NOT NULL
38
+ );
39
+ CREATE TABLE IF NOT EXISTS execution_owner (
40
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1), pid INTEGER NOT NULL, token TEXT NOT NULL
41
+ );
42
+ CREATE TABLE IF NOT EXISTS scene_operations (
43
+ target_key TEXT NOT NULL, id TEXT NOT NULL, revision INTEGER NOT NULL, data TEXT NOT NULL,
44
+ PRIMARY KEY (target_key, id)
45
+ );
46
+ CREATE TABLE IF NOT EXISTS repair_states (
47
+ target_key TEXT PRIMARY KEY, revision INTEGER NOT NULL, data TEXT NOT NULL
48
+ );
49
+ PRAGMA user_version = 4;
50
50
  `);
51
51
  }
52
52
  repairStateStore(targetKey) {
@@ -127,13 +127,13 @@ export class SqliteSparkStore {
127
127
  if (result.changes !== 1)
128
128
  throw new Error("Scene operation changed concurrently or is missing");
129
129
  if (next.status === "committed" || next.status === "rolled_back") {
130
- this.database.prepare(`
131
- DELETE FROM scene_operations WHERE rowid IN (
132
- SELECT rowid FROM scene_operations
133
- WHERE target_key = ? AND json_extract(data, '$.status') IN ('committed', 'rolled_back')
134
- ORDER BY COALESCE(json_extract(data, '$.completedAt'), '') DESC
135
- LIMIT -1 OFFSET ?
136
- )
130
+ this.database.prepare(`
131
+ DELETE FROM scene_operations WHERE rowid IN (
132
+ SELECT rowid FROM scene_operations
133
+ WHERE target_key = ? AND json_extract(data, '$.status') IN ('committed', 'rolled_back')
134
+ ORDER BY COALESCE(json_extract(data, '$.completedAt'), '') DESC
135
+ LIMIT -1 OFFSET ?
136
+ )
137
137
  `).run(next.targetKey, completedSceneOperationLimit);
138
138
  }
139
139
  this.database.exec("COMMIT");
@@ -163,6 +163,9 @@ export class SqliteSparkStore {
163
163
  if (result.changes === 0)
164
164
  throw new Error(`Conversation ${value.id} does not exist.`);
165
165
  }
166
+ async releaseActiveRun(conversationId, runId, updatedAt) {
167
+ this.database.prepare("UPDATE conversations SET updated_at = ?, data = json_set(json_remove(data, '$.activeRunId'), '$.updatedAt', ?) WHERE id = ? AND json_extract(data, '$.activeRunId') = ?").run(updatedAt, updatedAt, conversationId, runId);
168
+ }
166
169
  async saveConversationSettings(id, settings) {
167
170
  const result = this.database.prepare("UPDATE conversations SET data = json_set(data, '$.settings', json(?)) WHERE id = ?").run(JSON.stringify(settings), id);
168
171
  if (result.changes === 0)
@@ -21,6 +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
25
  export async function checkStandaloneServices(api) {
25
26
  const [fonts, openScad] = await Promise.allSettled([
26
27
  api.listFonts(),
@@ -66,6 +67,8 @@ export async function createStandaloneSparkAgentApp(options) {
66
67
  context.signal.throwIfAborted();
67
68
  await api.connect();
68
69
  context.signal.throwIfAborted();
70
+ await initializeStandaloneScene(api, context.signal);
71
+ context.signal.throwIfAborted();
69
72
  const support = await checkStandaloneServices(api);
70
73
  const services = {
71
74
  fonts: support.fonts,
@@ -8,6 +8,7 @@ export declare class MemorySparkAgentStore implements SparkAgentStore {
8
8
  getConversation(id: string): Promise<SparkConversation | undefined>;
9
9
  listConversations(ownerId: string): Promise<SparkConversationSummary[]>;
10
10
  saveConversation(conversation: SparkConversation): Promise<void>;
11
+ releaseActiveRun(conversationId: string, runId: string, updatedAt: string): Promise<void>;
11
12
  saveConversationSettings(id: string, settings: SparkRunSettings): Promise<void>;
12
13
  deleteConversation(id: string): Promise<boolean>;
13
14
  createRun(run: SparkRun): Promise<void>;
@@ -32,6 +32,13 @@ export class MemorySparkAgentStore {
32
32
  const settings = this.conversations.get(conversation.id).settings;
33
33
  this.conversations.set(conversation.id, copy({ ...history, ...(settings ? { settings } : {}) }));
34
34
  }
35
+ async releaseActiveRun(conversationId, runId, updatedAt) {
36
+ const conversation = this.conversations.get(conversationId);
37
+ if (conversation === undefined || conversation.activeRunId !== runId)
38
+ return;
39
+ conversation.activeRunId = undefined;
40
+ conversation.updatedAt = updatedAt;
41
+ }
35
42
  async saveConversationSettings(id, settings) {
36
43
  const conversation = this.conversations.get(id);
37
44
  if (conversation === undefined)
@@ -57,4 +57,11 @@ export interface SparkAgentStore {
57
57
  saveRun(run: SparkRun): Promise<void>;
58
58
  appendRunChunk(runId: string, conversationId: string, chunk: StoredSparkChunk["chunk"]): Promise<StoredSparkChunk>;
59
59
  listRunChunks(runId: string, afterSequence: number): Promise<StoredSparkChunk[]>;
60
+ /**
61
+ * Clears the conversation's active run only while it is still `runId`,
62
+ * touching nothing else on it. Optional: without it, the service releases
63
+ * a run by reading the conversation and saving it back, serialised against
64
+ * its own submits in this process.
65
+ */
66
+ releaseActiveRun?(conversationId: string, runId: string, updatedAt: string): Promise<void>;
60
67
  }