deepspace 0.20.0 → 0.21.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 (43) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/{chunk-A7UFIOWO.js → chunk-KDCDRHBA.js} +7 -2
  3. package/dist/chunk-KDCDRHBA.js.map +1 -0
  4. package/dist/cli.js +193 -62
  5. package/dist/cli.js.map +1 -1
  6. package/dist/documentation-client-core.d.ts +3 -2
  7. package/dist/documentation-client-core.js +1 -1
  8. package/dist/documentation-default-renderer.cjs +48 -48
  9. package/dist/documentation-react.d.ts +2 -1
  10. package/dist/documentation-react.js +1 -1
  11. package/dist/documentation-runtime.js +7 -7
  12. package/dist/documentation-server-core.d.ts +3 -2
  13. package/dist/documentation-server-core.js +1 -1
  14. package/dist/documentation.d.ts +40 -12
  15. package/dist/documentation.js +40 -7
  16. package/dist/documentation.js.map +1 -1
  17. package/dist/index.d.ts +225 -21
  18. package/dist/index.js +239 -100
  19. package/dist/index.js.map +1 -1
  20. package/dist/{public-CFAj1hL2.d.ts → public-DenQ1TsZ.d.ts} +36 -11
  21. package/dist/server.d.ts +21 -0
  22. package/dist/server.js +148 -39
  23. package/dist/server.js.map +1 -1
  24. package/dist/testing-mcp.d.ts +69 -0
  25. package/dist/testing-mcp.js +99 -0
  26. package/dist/testing-mcp.js.map +1 -0
  27. package/dist/testing.d.ts +17 -20
  28. package/dist/testing.js +47 -25
  29. package/dist/testing.js.map +1 -1
  30. package/dist/worker.d.ts +48 -3
  31. package/dist/worker.js +234 -62
  32. package/dist/worker.js.map +1 -1
  33. package/features/ai-chat/feature.json +2 -2
  34. package/features/ai-chat/src/AiChatPage.tsx +56 -21
  35. package/features/ai-chat/src/ChatPanel.tsx +33 -10
  36. package/features/ai-chat/src/__tests__/ChatPanel.lifecycle.test.tsx +156 -0
  37. package/features/cron/src/CronLogPage.tsx +107 -69
  38. package/features/file-attachments/feature.json +2 -1
  39. package/features/file-manager/feature.json +2 -1
  40. package/features/file-manager/src/FileManagerPage.tsx +227 -194
  41. package/features/integration-test/src/IntegrationTestPage.tsx +54 -34
  42. package/package.json +5 -1
  43. package/dist/chunk-A7UFIOWO.js.map +0 -1
@@ -1,4 +1,24 @@
1
1
  import { ReactNode, ReactElement, ComponentType } from 'react';
2
+ import { z } from 'zod';
3
+
4
+ declare const themeObjectSchema: z.ZodObject<{
5
+ accent: z.ZodOptional<z.ZodString>;
6
+ accentDark: z.ZodOptional<z.ZodString>;
7
+ background: z.ZodOptional<z.ZodString>;
8
+ density: z.ZodOptional<z.ZodEnum<{
9
+ comfortable: "comfortable";
10
+ compact: "compact";
11
+ }>>;
12
+ logo: z.ZodOptional<z.ZodString>;
13
+ logoDark: z.ZodOptional<z.ZodString>;
14
+ favicon: z.ZodOptional<z.ZodString>;
15
+ defaultMode: z.ZodOptional<z.ZodEnum<{
16
+ light: "light";
17
+ dark: "dark";
18
+ system: "system";
19
+ }>>;
20
+ strictMode: z.ZodOptional<z.ZodBoolean>;
21
+ }, z.core.$strip>;
2
22
 
3
23
  type DocumentationAssistantAccess = 'disabled' | 'public' | 'authenticated';
4
24
  type DocumentationMcpAccess = 'disabled' | 'public';
@@ -10,11 +30,21 @@ interface DocumentationFontConfig {
10
30
  source?: string;
11
31
  format?: string;
12
32
  }
13
- interface DocumentationThemeConfig {
14
- preset?: string;
15
- accent?: string;
16
- accentDark?: string;
17
- background?: string;
33
+ /**
34
+ * The `theme` object as authored in documentation.json — exactly the keys
35
+ * the config schema accepts under `theme.*`, derived from that schema so
36
+ * the two can never drift. Everything else (fonts, background decoration,
37
+ * styling, logo href) is configured through the Mintlify-style top-level
38
+ * keys and only exists on the resolved theme.
39
+ */
40
+ type DocumentationThemeConfig = z.infer<typeof themeObjectSchema>;
41
+ /**
42
+ * The fully resolved theme after config loading merges `theme.*` with the
43
+ * Mintlify top-level keys (`colors`, `logo`, `favicon`, `appearance`,
44
+ * `background`, `fonts`, `styling`). This is what the template and runtime
45
+ * consume; it is an output shape, never accepted as `theme.*` input.
46
+ */
47
+ interface ResolvedDocumentationTheme extends DocumentationThemeConfig {
18
48
  backgroundDark?: string;
19
49
  backgroundDecoration?: 'none' | 'gradient' | 'grid';
20
50
  bodyFont?: DocumentationFontConfig;
@@ -22,12 +52,7 @@ interface DocumentationThemeConfig {
22
52
  monoFont?: DocumentationFontConfig;
23
53
  codeBlockMode?: 'dark' | 'system';
24
54
  eyebrowStyle?: 'section' | 'breadcrumbs' | 'none';
25
- logo?: string;
26
- logoDark?: string;
27
55
  logoHref?: string;
28
- favicon?: string;
29
- defaultMode?: 'light' | 'dark' | 'system';
30
- strictMode?: boolean;
31
56
  }
32
57
  interface DocumentationLinkConfig {
33
58
  label: string;
@@ -79,7 +104,7 @@ interface DocumentationConfig {
79
104
  url?: string;
80
105
  /** Explicit custom hostnames that mount this documentation at `/`. */
81
106
  domains: string[];
82
- theme: DocumentationThemeConfig;
107
+ theme: ResolvedDocumentationTheme;
83
108
  navigation?: DocumentationNavigationItem[];
84
109
  links: DocumentationLinkConfig[];
85
110
  footer: DocumentationLinkConfig[];
package/dist/server.d.ts CHANGED
@@ -1532,6 +1532,7 @@ declare const MSG: {
1532
1532
  readonly CRON_PAUSE: "cron.pause";
1533
1533
  readonly CRON_RESUME: "cron.resume";
1534
1534
  readonly CRON_STATUS: "cron.status";
1535
+ readonly CRON_ACK: "cron.ack";
1535
1536
  readonly JOB_ENQUEUE: "job.enqueue";
1536
1537
  readonly JOB_CANCEL: "job.cancel";
1537
1538
  readonly JOB_RETRY: "job.retry";
@@ -1699,6 +1700,16 @@ type ServerMessage = BaseMessage<typeof MSG.QUERY_RESULT, {
1699
1700
  }> | BaseMessage<typeof MSG.CRON_STATUS, {
1700
1701
  tasks: unknown;
1701
1702
  recentHistory: unknown;
1703
+ }> | BaseMessage<typeof MSG.CRON_ACK, {
1704
+ requestId: string;
1705
+ taskName: string;
1706
+ ok: true;
1707
+ } | {
1708
+ requestId: string;
1709
+ taskName?: string;
1710
+ ok: false;
1711
+ reason: 'read_only' | 'unknown_task' | 'failed';
1712
+ error?: string;
1702
1713
  }> | BaseMessage<typeof MSG.JOB_UPDATE, {
1703
1714
  kind: 'snapshot' | 'enqueued' | 'progress' | 'succeeded' | 'failed' | 'canceled' | 'retried';
1704
1715
  job?: unknown;
@@ -2313,12 +2324,22 @@ declare abstract class CronRoom<E = Record<string, unknown>> extends BaseRoom<E>
2313
2324
  type: string;
2314
2325
  [key: string]: unknown;
2315
2326
  }): Promise<void>;
2327
+ private dispatchMessage;
2316
2328
  protected onAlarm(): Promise<void>;
2317
2329
  private executeTask;
2318
2330
  private scheduleNextAlarm;
2319
2331
  private getTaskStates;
2320
2332
  private getRecentHistory;
2321
2333
  private broadcastStatus;
2334
+ /**
2335
+ * Receipt for a mutation frame, addressed only to its sender. Receipts
2336
+ * are opt-in by correlation id: frames without a `requestId` keep the
2337
+ * fire-and-forget contract, so the (untyped) `requestId` from the wire
2338
+ * is the single gate here rather than a check at every call site.
2339
+ * Frames go through the shared `serverBuild` constructors so the wire
2340
+ * shape is enforced by the protocol types, not re-spelled here.
2341
+ */
2342
+ private ackMutation;
2322
2343
  /**
2323
2344
  * Execute a scheduled task by name.
2324
2345
  * Called both by the alarm scheduler and manual trigger.
package/dist/server.js CHANGED
@@ -740,6 +740,9 @@ function declaredLength(request, what) {
740
740
  function isKeyWithinScope(key, prefix, excludedPrefixes) {
741
741
  return key.startsWith(prefix) && !excludedPrefixes.some((excluded) => key.startsWith(excluded));
742
742
  }
743
+ function scopeRelativeKey(key, prefix) {
744
+ return key.slice(prefix.length);
745
+ }
743
746
  function anchorKey(raw, prefix, excludedPrefixes) {
744
747
  const safe = sanitizeSubpath(raw);
745
748
  if (safe === null) return fail2(400, "Invalid key: traversal not allowed", "invalid_key");
@@ -939,7 +942,7 @@ async function handleUpload(request, url, bucket, prefix, excludedPrefixes, auth
939
942
  admission.markerKey,
940
943
  admission.markerKey !== null || fileData.byteLength < replacedBytes
941
944
  );
942
- return storedFileJson(located.key, fileName, url, scope);
945
+ return storedFileJson(located.key, prefix, fileName, url, scope);
943
946
  } catch (err) {
944
947
  const msg = err instanceof Error ? err.message : "Upload failed";
945
948
  console.error("[handleUpload] Error:", msg, err instanceof Error ? err.stack : "");
@@ -961,11 +964,17 @@ function fileMetadata(fileName, userId, reservationId, declaredBytes) {
961
964
  uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
962
965
  };
963
966
  }
964
- function storedFileJson(key, name, url, scope) {
967
+ function storedFileJson(key, prefix, name, url, scope) {
965
968
  const fileUrl = new URL(`/api/files/${encodeKeyPath(key)}`, url.origin);
966
969
  fileUrl.searchParams.set("scope", scope);
967
970
  return Response.json(
968
- { success: true, key, url: fileUrl.toString(), name },
971
+ {
972
+ success: true,
973
+ key,
974
+ relativeKey: scopeRelativeKey(key, prefix),
975
+ url: fileUrl.toString(),
976
+ name
977
+ },
969
978
  { headers: CORS_HEADERS2 }
970
979
  );
971
980
  }
@@ -1245,7 +1254,7 @@ async function completedReservationObject(bucket, session) {
1245
1254
  const object = await bucket.head(session.key);
1246
1255
  return object?.customMetadata?.reservationId === session.reservationId ? object : null;
1247
1256
  }
1248
- async function finishCompletedMultipart(bucket, session, reservation, object, auth, url, scope) {
1257
+ async function finishCompletedMultipart(bucket, session, reservation, object, auth, url, scope, prefix) {
1249
1258
  const declaredMetadata = Number(object.customMetadata?.declaredBytes);
1250
1259
  const declaredBytes = reservation?.declaredBytes ?? (Number.isSafeInteger(declaredMetadata) && declaredMetadata >= 0 ? declaredMetadata : MAX_APP_FILE_BYTES);
1251
1260
  const refusal = overCeiling(object.size) ?? (object.size > declaredBytes ? fail2(
@@ -1279,7 +1288,7 @@ async function finishCompletedMultipart(bucket, session, reservation, object, au
1279
1288
  }
1280
1289
  }
1281
1290
  const name = object.customMetadata?.originalName ?? session.uploadKey.split("/").pop() ?? "file";
1282
- return storedFileJson(session.key, name, url, scope);
1291
+ return storedFileJson(session.key, prefix, name, url, scope);
1283
1292
  }
1284
1293
  async function multipartComplete(request, url, bucket, prefix, excludedPrefixes, auth, scope) {
1285
1294
  const body = await readControlJson(request);
@@ -1300,7 +1309,7 @@ async function multipartComplete(request, url, bucket, prefix, excludedPrefixes,
1300
1309
  if (auth.storage && !reservation) {
1301
1310
  const completed = await completedReservationObject(bucket, session);
1302
1311
  if (completed) {
1303
- return finishCompletedMultipart(bucket, session, null, completed, auth, url, scope);
1312
+ return finishCompletedMultipart(bucket, session, null, completed, auth, url, scope, prefix);
1304
1313
  }
1305
1314
  await abandonMultipart(bucket, session, auth, null);
1306
1315
  return quotaUnavailable("This upload reservation is missing or does not match.");
@@ -1339,14 +1348,23 @@ async function multipartComplete(request, url, bucket, prefix, excludedPrefixes,
1339
1348
  } catch (error) {
1340
1349
  const completed = await completedReservationObject(bucket, session);
1341
1350
  if (completed) {
1342
- return finishCompletedMultipart(bucket, session, reservation, completed, auth, url, scope);
1351
+ return finishCompletedMultipart(
1352
+ bucket,
1353
+ session,
1354
+ reservation,
1355
+ completed,
1356
+ auth,
1357
+ url,
1358
+ scope,
1359
+ prefix
1360
+ );
1343
1361
  }
1344
1362
  if (r2ErrorCode(error) === 10024 && reservation) {
1345
1363
  await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
1346
1364
  }
1347
1365
  return multipartFailure(error);
1348
1366
  }
1349
- return finishCompletedMultipart(bucket, session, reservation, object, auth, url, scope);
1367
+ return finishCompletedMultipart(bucket, session, reservation, object, auth, url, scope, prefix);
1350
1368
  }
1351
1369
  async function multipartAbort(url, bucket, prefix, excludedPrefixes, auth) {
1352
1370
  const session = resolveSession(url, prefix, excludedPrefixes);
@@ -1366,37 +1384,49 @@ async function handleList(bucket, prefix, excludedPrefixes, url, scope, auth) {
1366
1384
  const listPrefix = `${prefix}${userPrefix}`;
1367
1385
  const requestedLimit = Number(url.searchParams.get("limit") ?? 100);
1368
1386
  const limit = Number.isSafeInteger(requestedLimit) ? Math.min(1e3, Math.max(1, requestedLimit)) : 100;
1387
+ const requestCursor = url.searchParams.get("cursor");
1369
1388
  if (excludedPrefixes.some((excluded) => listPrefix.startsWith(excluded))) {
1370
1389
  return Response.json({ files: [], truncated: false }, { headers: CORS_HEADERS2 });
1371
1390
  }
1372
- const listed = await bucket.list({ prefix: listPrefix, limit });
1391
+ const withMetadata = (options) => ({ ...options, include: ["customMetadata"] });
1392
+ const listed = await bucket.list(
1393
+ withMetadata({
1394
+ prefix: listPrefix,
1395
+ limit,
1396
+ ...requestCursor ? { cursor: requestCursor } : {}
1397
+ })
1398
+ );
1373
1399
  const visible = listed.objects.filter(
1374
1400
  (object) => isKeyWithinScope(object.key, prefix, excludedPrefixes)
1375
1401
  );
1376
1402
  const seen = new Set(visible.map((object) => object.key));
1377
- let truncated = listed.truncated;
1378
- for (const excluded of excludedPrefixes.filter((candidate) => candidate.startsWith(listPrefix))) {
1379
- if (visible.length >= limit) break;
1403
+ let frontier = listed;
1404
+ let frontierKey = listed.objects.at(-1)?.key;
1405
+ for (const excluded of excludedPrefixes.filter((candidate) => candidate.startsWith(listPrefix)).sort()) {
1406
+ if (!frontier.truncated || visible.length >= limit) break;
1380
1407
  const excludedEnd = `${excluded}\u{10FFFF}`;
1381
- const lastListedKey = listed.objects.at(-1)?.key;
1382
- const after = await bucket.list({
1383
- prefix: listPrefix,
1384
- startAfter: lastListedKey && lastListedKey > excludedEnd ? lastListedKey : excludedEnd,
1385
- limit: limit - visible.length
1386
- });
1408
+ const after = await bucket.list(
1409
+ withMetadata({
1410
+ prefix: listPrefix,
1411
+ startAfter: frontierKey && frontierKey > excludedEnd ? frontierKey : excludedEnd,
1412
+ limit: limit - visible.length
1413
+ })
1414
+ );
1387
1415
  for (const object of after.objects) {
1388
1416
  if (!seen.has(object.key) && isKeyWithinScope(object.key, prefix, excludedPrefixes)) {
1389
1417
  seen.add(object.key);
1390
1418
  visible.push(object);
1391
1419
  }
1392
1420
  }
1393
- truncated = after.truncated;
1421
+ frontierKey = after.objects.at(-1)?.key ?? frontierKey;
1422
+ frontier = after;
1394
1423
  }
1395
1424
  const files = visible.slice(0, limit).map((obj) => {
1396
1425
  const fileUrl = new URL(`/api/files/${encodeKeyPath(obj.key)}`, url.origin);
1397
1426
  fileUrl.searchParams.set("scope", scope);
1398
1427
  return {
1399
1428
  key: obj.key,
1429
+ relativeKey: scopeRelativeKey(obj.key, prefix),
1400
1430
  size: obj.size,
1401
1431
  uploaded: obj.uploaded.toISOString(),
1402
1432
  url: fileUrl.toString(),
@@ -1411,7 +1441,12 @@ async function handleList(bucket, prefix, excludedPrefixes, url, scope, auth) {
1411
1441
  if (storage && limitBytes !== null) storage.limitBytes = limitBytes;
1412
1442
  }
1413
1443
  return Response.json(
1414
- { files, truncated, ...storage ? { storage } : {} },
1444
+ {
1445
+ files,
1446
+ truncated: frontier.truncated,
1447
+ ...frontier.truncated ? { cursor: frontier.cursor } : {},
1448
+ ...storage ? { storage } : {}
1449
+ },
1415
1450
  { headers: CORS_HEADERS2 }
1416
1451
  );
1417
1452
  }
@@ -2952,6 +2987,9 @@ var MSG = {
2952
2987
  CRON_PAUSE: "cron.pause",
2953
2988
  CRON_RESUME: "cron.resume",
2954
2989
  CRON_STATUS: "cron.status",
2990
+ // Per-mutation receipt, sent only to the socket whose trigger/pause/
2991
+ // resume carried a `requestId` — frames without one stay fire-and-forget.
2992
+ CRON_ACK: "cron.ack",
2955
2993
  // ---- JobRoom ---------------------------------------------------------
2956
2994
  // Background-job queue with durable, alarm-driven execution. See
2957
2995
  // server/rooms/job-room.ts. Clients enqueue and subscribe to updates;
@@ -3453,6 +3491,14 @@ var serverBuild = {
3453
3491
  cronTasks: (tasks) => ({ type: MSG.CRON_TASKS, payload: { tasks } }),
3454
3492
  cronHistory: (history) => ({ type: MSG.CRON_HISTORY, payload: { history } }),
3455
3493
  cronStatus: (tasks, recentHistory) => ({ type: MSG.CRON_STATUS, payload: { tasks, recentHistory } }),
3494
+ cronAckSuccess: (requestId, taskName) => ({
3495
+ type: MSG.CRON_ACK,
3496
+ payload: { requestId, taskName, ok: true }
3497
+ }),
3498
+ cronAckFailure: (requestId, reason, error, taskName) => ({
3499
+ type: MSG.CRON_ACK,
3500
+ payload: { requestId, taskName, ok: false, reason, error }
3501
+ }),
3456
3502
  // Jobs
3457
3503
  jobSnapshot: (jobs) => ({ type: MSG.JOB_UPDATE, payload: { kind: "snapshot", jobs } }),
3458
3504
  jobEnqueued: (job, requestId) => ({
@@ -6196,40 +6242,88 @@ var CronRoom = class extends BaseRoom {
6196
6242
  }
6197
6243
  async onMessage(ws, user, message) {
6198
6244
  this.ensureInitialized();
6199
- const { type, payload } = message;
6245
+ const { type, payload = {} } = message;
6200
6246
  if (CRON_WRITE_TYPES.has(type) && !user.canWrite) {
6201
- this.sendTo(ws, {
6202
- type: MSG.ERROR,
6203
- payload: { error: "Write access denied: viewer role cannot modify cron tasks" }
6247
+ const error = "Write access denied: viewer role cannot modify cron tasks";
6248
+ this.sendTo(ws, { type: MSG.ERROR, payload: { error } });
6249
+ this.ackMutation(ws, payload.requestId, {
6250
+ ok: false,
6251
+ taskName: payload.taskName,
6252
+ reason: "read_only",
6253
+ error
6204
6254
  });
6205
6255
  return;
6206
6256
  }
6257
+ try {
6258
+ await this.dispatchMessage(ws, type, payload);
6259
+ } catch (error) {
6260
+ try {
6261
+ this.ackMutation(ws, payload.requestId, {
6262
+ ok: false,
6263
+ taskName: payload.taskName,
6264
+ reason: "failed",
6265
+ error: error instanceof Error ? error.message : String(error)
6266
+ });
6267
+ } catch {
6268
+ }
6269
+ throw error;
6270
+ }
6271
+ }
6272
+ async dispatchMessage(ws, type, payload) {
6207
6273
  switch (type) {
6208
6274
  case MSG.CRON_TRIGGER: {
6209
6275
  const taskName = payload.taskName;
6210
6276
  if (!taskName) {
6211
- this.sendTo(ws, { type: MSG.ERROR, payload: { error: "Missing taskName" } });
6277
+ const error = "Missing taskName";
6278
+ this.sendTo(ws, { type: MSG.ERROR, payload: { error } });
6279
+ this.ackMutation(ws, payload.requestId, { ok: false, reason: "failed", error });
6212
6280
  return;
6213
6281
  }
6214
- if (!await this.executeTask(taskName)) {
6215
- this.sendTo(ws, {
6216
- type: MSG.ERROR,
6217
- payload: { error: `Unknown cron task: ${taskName}` }
6282
+ const execution = await this.executeTask(taskName);
6283
+ if (!execution) {
6284
+ const error = `Unknown cron task: ${taskName}`;
6285
+ this.sendTo(ws, { type: MSG.ERROR, payload: { error } });
6286
+ this.ackMutation(ws, payload.requestId, {
6287
+ ok: false,
6288
+ taskName,
6289
+ reason: "unknown_task",
6290
+ error
6218
6291
  });
6292
+ break;
6219
6293
  }
6294
+ this.ackMutation(
6295
+ ws,
6296
+ payload.requestId,
6297
+ execution.success ? { ok: true, taskName } : { ok: false, taskName, reason: "failed", error: execution.error }
6298
+ );
6220
6299
  break;
6221
6300
  }
6222
- case MSG.CRON_PAUSE: {
6223
- const taskName = payload.taskName;
6224
- this.sql.exec(`UPDATE cron_tasks SET paused = 1 WHERE name = ?`, taskName);
6225
- this.broadcastStatus();
6226
- break;
6227
- }
6301
+ case MSG.CRON_PAUSE:
6228
6302
  case MSG.CRON_RESUME: {
6229
6303
  const taskName = payload.taskName;
6230
- this.sql.exec(`UPDATE cron_tasks SET paused = 0 WHERE name = ?`, taskName);
6231
- this.scheduleNextAlarm();
6304
+ if (!taskName) {
6305
+ const error = "Missing taskName";
6306
+ this.sendTo(ws, { type: MSG.ERROR, payload: { error } });
6307
+ this.ackMutation(ws, payload.requestId, { ok: false, reason: "failed", error });
6308
+ return;
6309
+ }
6310
+ const known = this.sql.exec(`SELECT name FROM cron_tasks WHERE name = ?`, taskName).toArray().length > 0;
6311
+ if (!known) {
6312
+ const error = `Unknown cron task: ${taskName}`;
6313
+ this.sendTo(ws, { type: MSG.ERROR, payload: { error } });
6314
+ this.ackMutation(ws, payload.requestId, {
6315
+ ok: false,
6316
+ taskName,
6317
+ reason: "unknown_task",
6318
+ error
6319
+ });
6320
+ break;
6321
+ }
6322
+ const paused = type === MSG.CRON_PAUSE;
6323
+ this.sql.exec(`UPDATE cron_tasks SET paused = ? WHERE name = ?`, paused ? 1 : 0, taskName);
6324
+ if (!paused) this.scheduleNextAlarm();
6232
6325
  this.broadcastStatus();
6326
+ this.ackMutation(ws, payload.requestId, { ok: true, taskName });
6233
6327
  break;
6234
6328
  }
6235
6329
  case MSG.CRON_TASKS: {
@@ -6272,7 +6366,7 @@ var CronRoom = class extends BaseRoom {
6272
6366
  // ==========================================================================
6273
6367
  async executeTask(taskName) {
6274
6368
  const taskRow = this.sql.exec(`SELECT interval_minutes, schedule, timezone FROM cron_tasks WHERE name = ?`, taskName).toArray()[0];
6275
- if (!taskRow) return false;
6369
+ if (!taskRow) return null;
6276
6370
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
6277
6371
  const start = Date.now();
6278
6372
  let success = true;
@@ -6308,7 +6402,7 @@ var CronRoom = class extends BaseRoom {
6308
6402
  `DELETE FROM cron_history WHERE id NOT IN (SELECT id FROM cron_history ORDER BY id DESC LIMIT 500)`
6309
6403
  );
6310
6404
  this.broadcastStatus();
6311
- return true;
6405
+ return { taskName, startedAt, completedAt, success, durationMs, error };
6312
6406
  }
6313
6407
  // ==========================================================================
6314
6408
  // Scheduling
@@ -6378,6 +6472,21 @@ var CronRoom = class extends BaseRoom {
6378
6472
  }
6379
6473
  });
6380
6474
  }
6475
+ /**
6476
+ * Receipt for a mutation frame, addressed only to its sender. Receipts
6477
+ * are opt-in by correlation id: frames without a `requestId` keep the
6478
+ * fire-and-forget contract, so the (untyped) `requestId` from the wire
6479
+ * is the single gate here rather than a check at every call site.
6480
+ * Frames go through the shared `serverBuild` constructors so the wire
6481
+ * shape is enforced by the protocol types, not re-spelled here.
6482
+ */
6483
+ ackMutation(ws, requestId, result) {
6484
+ if (typeof requestId !== "string") return;
6485
+ this.sendTo(
6486
+ ws,
6487
+ result.ok ? serverBuild.cronAckSuccess(requestId, result.taskName) : serverBuild.cronAckFailure(requestId, result.reason, result.error, result.taskName)
6488
+ );
6489
+ }
6381
6490
  };
6382
6491
  function computeNextRunAt(task, from) {
6383
6492
  if (task.interval_minutes) {