@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.
- package/.env.example +6 -6
- package/README.md +457 -436
- package/dist/agent-run.d.ts +1 -1
- package/dist/capabilities/flow.js +3 -3
- package/dist/capabilities/geometry.js +14 -14
- package/dist/capabilities/openscad-instructions.d.ts +1 -1
- package/dist/capabilities/openscad-instructions.js +2 -1
- package/dist/capabilities/operations.js +33 -32
- package/dist/capabilities/scene.js +74 -74
- package/dist/client.js +8 -0
- package/dist/geometry/schemas/backend-schema.json +3393 -3393
- package/dist/geometry/schemas/flowir-dialect.json +1023 -1023
- package/dist/geometry.js +6 -6
- package/dist/models.d.ts +12 -5
- package/dist/models.js +16 -7
- package/dist/pricing.js +15 -0
- package/dist/prompt.d.ts +1 -1
- package/dist/prompt.js +65 -70
- package/dist/server/express.js +9 -1
- package/dist/server/fixtures/materials/matte-blue.json +14 -14
- package/dist/server/fixtures/materials/polished-copper.json +14 -14
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.js +1 -1
- package/dist/server/message-fold.d.ts +59 -0
- package/dist/server/message-fold.js +345 -0
- package/dist/server/scene-initialization.d.ts +3 -0
- package/dist/server/scene-initialization.js +44 -0
- package/dist/server/service.d.ts +82 -5
- package/dist/server/service.js +294 -63
- package/dist/server/sqlite-store.d.ts +1 -0
- package/dist/server/sqlite-store.js +40 -37
- package/dist/server/standalone.js +3 -0
- package/dist/server/store.d.ts +1 -0
- package/dist/server/store.js +7 -0
- package/dist/server/types.d.ts +7 -0
- package/package.json +139 -139
package/dist/server/service.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sparkErrorDiagnostics } from "../error-diagnostics.js";
|
|
2
|
-
import {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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(() =>
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/dist/server/store.d.ts
CHANGED
|
@@ -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>;
|
package/dist/server/store.js
CHANGED
|
@@ -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)
|
package/dist/server/types.d.ts
CHANGED
|
@@ -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
|
}
|