@frockbot/plugin-bot-template 0.3.24 → 0.3.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-bot-template",
3
- "version": "0.3.24",
3
+ "version": "0.3.25",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -24,20 +24,20 @@
24
24
  "typecheck": "vue-tsc --noEmit -p tsconfig.json"
25
25
  },
26
26
  "dependencies": {
27
- "@frockbot/client-core": "0.3.24",
28
- "@frockbot/client-ui": "0.3.24",
29
- "@frockbot/configuration-core": "0.3.24",
30
- "@frockbot/connection-core": "0.3.24",
31
- "@frockbot/kernel-agent-loop": "0.3.24",
32
- "@frockbot/kernel-contracts": "0.3.24",
33
- "@frockbot/plugin-settings": "0.3.24",
34
- "@frockbot/plugin-shell": "0.3.24",
35
- "@frockbot/template-core": "0.3.24",
27
+ "@frockbot/client-core": "0.3.25",
28
+ "@frockbot/client-ui": "0.3.25",
29
+ "@frockbot/configuration-core": "0.3.25",
30
+ "@frockbot/connection-core": "0.3.25",
31
+ "@frockbot/kernel-agent-loop": "0.3.25",
32
+ "@frockbot/kernel-contracts": "0.3.25",
33
+ "@frockbot/plugin-settings": "0.3.25",
34
+ "@frockbot/plugin-shell": "0.3.25",
35
+ "@frockbot/template-core": "0.3.25",
36
36
  "cordis": "4.0.0-rc.8",
37
37
  "vue": "3.5.41"
38
38
  },
39
39
  "devDependencies": {
40
- "@frockbot/plugin-tools": "0.3.24",
40
+ "@frockbot/plugin-tools": "0.3.25",
41
41
  "@types/bun": "1.4.0",
42
42
  "@vitejs/plugin-vue": "6.0.8",
43
43
  "typescript": "npm:typescript-native-bridge@6.0.3-bridge.16.tsgo.7.0.2",
@@ -35,6 +35,14 @@ const sheep = {
35
35
 
36
36
  class MemoryStorage implements UserSettingsStorage {
37
37
  readonly values = new Map<string, unknown>();
38
+ /**
39
+ * The order durable writes and alarm arming happened in. An import that is
40
+ * mid-apply has to be recoverable from the alarm alone, so "the deadline was
41
+ * recorded before the record said `applying`" is the claim, not merely "an
42
+ * alarm was set at some point".
43
+ */
44
+ readonly trace: string[] = [];
45
+ alarm: number | null = null;
38
46
  get<T>(key: string): Promise<T | undefined> {
39
47
  return Promise.resolve(this.values.get(key) as T | undefined);
40
48
  }
@@ -44,14 +52,35 @@ class MemoryStorage implements UserSettingsStorage {
44
52
  keyOrEntries: string | Record<string, unknown>,
45
53
  value?: T,
46
54
  ): Promise<void> {
47
- if (typeof keyOrEntries === "string") this.values.set(keyOrEntries, value);
55
+ if (typeof keyOrEntries === "string") this.record(keyOrEntries, value);
48
56
  else {
49
57
  for (const [key, entry] of Object.entries(keyOrEntries)) {
50
- this.values.set(key, entry);
58
+ this.record(key, entry);
51
59
  }
52
60
  }
53
61
  return Promise.resolve();
54
62
  }
63
+ getAlarm(): Promise<number | null> {
64
+ return Promise.resolve(this.alarm);
65
+ }
66
+ setAlarm(scheduledTime: number | Date): Promise<void> {
67
+ this.alarm =
68
+ typeof scheduledTime === "number"
69
+ ? scheduledTime
70
+ : scheduledTime.getTime();
71
+ this.trace.push("alarm:set");
72
+ return Promise.resolve();
73
+ }
74
+ private record(key: string, value: unknown): void {
75
+ this.values.set(key, value);
76
+ if (key === "bot-template:import-recovery-at") {
77
+ this.trace.push(value ? "recovery:owed" : "recovery:clear");
78
+ }
79
+ const status = (value as { status?: unknown } | undefined)?.status;
80
+ if (key.startsWith("bot-template:import:") && typeof status === "string") {
81
+ this.trace.push(`import:${status}`);
82
+ }
83
+ }
55
84
  async transaction<T>(
56
85
  callback: (storage: UserSettingsTransaction) => Promise<T>,
57
86
  ): Promise<T> {
@@ -438,6 +467,75 @@ describe("failure is a visible, repairable record", () => {
438
467
  ).toHaveLength(1);
439
468
  });
440
469
 
470
+ it("records the recovery deadline before the record says applying", async () => {
471
+ const { contribution, storage } = await harness();
472
+ await plan(contribution);
473
+ storage.trace.length = 0;
474
+ await apply(contribution);
475
+
476
+ // The deadline and the alarm are both in place before the record enters
477
+ // `applying`, so an eviction at any point after that leaves an object
478
+ // scheduled to finish the walk.
479
+ expect(storage.trace[0]).toBe("recovery:owed");
480
+ expect(storage.trace.indexOf("alarm:set")).toBeLessThan(
481
+ storage.trace.indexOf("import:applying"),
482
+ );
483
+ });
484
+
485
+ it("clears the recovery debt once the import is terminal", async () => {
486
+ const { contribution, storage } = await harness();
487
+ await plan(contribution);
488
+ await apply(contribution);
489
+ expect(storage.values.get("bot-template:import-recovery-at")).toBe(0);
490
+ });
491
+
492
+ it("records a visible failure and clears the debt when a step fails", async () => {
493
+ const failures: Record<string, string> = {};
494
+ const { contribution, storage } = await harness({ failures });
495
+ const record = await plan(contribution);
496
+ // The state an eviction mid-walk leaves: `applying`, with no process to
497
+ // finish it. This pass cannot finish it either, because the writer throws.
498
+ failures.install = "the Catalog is unreachable";
499
+ await storage.put(`bot-template:import:${record.importId}`, {
500
+ ...record,
501
+ status: "applying",
502
+ steps: record.steps.map((step) =>
503
+ step.kind === "bot/create" ? { ...step, status: "done" } : step,
504
+ ),
505
+ });
506
+ await contribution.recoverImports(USER);
507
+
508
+ // Failed is terminal and repairable — the card names the step and the
509
+ // reason — so nothing is owed a further recovery.
510
+ const listed = (await contribution.listImports(USER)).imports[0]!;
511
+ expect(listed.status).toBe("failed");
512
+ expect(listed.failure).toContain("the Catalog is unreachable");
513
+ expect(storage.values.get("bot-template:import-recovery-at")).toBe(0);
514
+ });
515
+
516
+ it("re-arms the alarm when a recovery pass leaves the import applying", async () => {
517
+ const { contribution, storage } = await harness();
518
+ const record = await plan(contribution);
519
+ await storage.put(`bot-template:import:${record.importId}`, {
520
+ ...record,
521
+ status: "applying",
522
+ });
523
+ // The plan the walk needs is unreadable, so the pass throws before any
524
+ // step runs and the record stays `applying`.
525
+ storage.values.delete(`bot-template:import:plan:${record.importId}`);
526
+ storage.alarm = null;
527
+ await contribution.recoverImports(USER);
528
+
529
+ const listed = (await contribution.listImports(USER)).imports[0]!;
530
+ expect(listed.status).toBe("applying");
531
+ // Still owed, and still scheduled: the next alarm tries again rather than
532
+ // the import waiting for unrelated work to wake the object.
533
+ expect(
534
+ storage.values.get("bot-template:import-recovery-at"),
535
+ ).toBeGreaterThan(0);
536
+ expect(storage.alarm).not.toBeNull();
537
+ });
538
+
441
539
  it("resumes an import left mid-apply from the recovery pass", async () => {
442
540
  const { contribution, recording, storage } = await harness();
443
541
  const record = await plan(contribution);
package/src/shared.ts CHANGED
@@ -422,6 +422,17 @@ export interface TemplateImportReceiptV1 {
422
422
  /** Most imports one User may hold, so planning cannot fill the object. */
423
423
  export const MAX_TEMPLATE_IMPORTS_V1 = 100;
424
424
 
425
+ /**
426
+ * Where one import record lives in the User Durable Object's storage.
427
+ *
428
+ * Named here rather than kept private to the Contribution because a durability
429
+ * test has to be able to leave an import in the state an eviction mid-apply
430
+ * would leave it in, without an RPC that would also finish it.
431
+ */
432
+ export function templateImportRecordKeyV1(importId: string): string {
433
+ return `bot-template:import:${importId}`;
434
+ }
435
+
425
436
  function importStatus(value: unknown): TemplateImportStatusV1 {
426
437
  const found = TEMPLATE_IMPORT_STATUSES_V1.find((known) => known === value);
427
438
  if (!found)
package/src/user.ts CHANGED
@@ -58,6 +58,7 @@ import {
58
58
  type TemplateImportRecordV1,
59
59
  type TemplateImportStepReceiptV1,
60
60
  templateCommandFingerprintV1,
61
+ templateImportRecordKeyV1,
61
62
  type TemplateCommandV1,
62
63
  type TemplateExportSummaryV1,
63
64
  type TemplateShareListViewV1,
@@ -70,6 +71,23 @@ export const BOT_TEMPLATE_PACKAGE_ID = "bot-template";
70
71
  const SHARE_PREFIX = "bot-template:share:";
71
72
  const IMPORT_PREFIX = "bot-template:import:";
72
73
  const IMPORT_INDEX_KEY = "bot-template:import-index";
74
+ /**
75
+ * The durable record that a recovery pass is owed.
76
+ *
77
+ * An apply that is evicted mid-walk leaves its record `applying` and nothing
78
+ * in the process to finish it, so the intent to recover is written down before
79
+ * the record enters that state and is cleared only once no import is mid-apply.
80
+ * A value of `0` means nothing is owed.
81
+ */
82
+ const IMPORT_RECOVERY_KEY = "bot-template:import-recovery-at";
83
+ /**
84
+ * How long after entering `applying` the alarm is asked to fire.
85
+ *
86
+ * Short, because it is a recovery deadline rather than a schedule: an apply
87
+ * that finishes in-process clears it before it matters, and one that does not
88
+ * is work a User is waiting on.
89
+ */
90
+ const IMPORT_RECOVERY_DELAY_MS = 5_000;
73
91
  const SHARE_INDEX_KEY = "bot-template:share-index";
74
92
  const RECEIPT_PREFIX = "bot-template:receipt:";
75
93
 
@@ -154,8 +172,22 @@ export interface TemplateImportWriterV1 {
154
172
  }): Promise<{ status: string; routineId?: string }>;
155
173
  }
156
174
 
175
+ /**
176
+ * This Package's slice of the User Durable Object's storage.
177
+ *
178
+ * `setAlarm` is how an import that is still mid-apply keeps its own recovery
179
+ * scheduled instead of waiting for some other owner of the object's single
180
+ * alarm to happen to wake it. Both alarm methods are optional so a host with
181
+ * no alarm at all still imports; on such a host an interrupted import stays
182
+ * visible and repairable rather than resuming by itself.
183
+ */
184
+ export interface BotTemplateStorageV1 extends UserSettingsStorage {
185
+ getAlarm?(): Promise<number | null>;
186
+ setAlarm?(scheduledTime: number | Date): Promise<void>;
187
+ }
188
+
157
189
  export interface BotTemplateUserHostV1 {
158
- storage: UserSettingsStorage;
190
+ storage: BotTemplateStorageV1;
159
191
  settings: UserSettingsBackendContribution;
160
192
  bots: TemplateBotReaderV1;
161
193
  blobs: TemplateBlobStoreV1;
@@ -613,6 +645,12 @@ export class BotTemplateUserBackendContribution {
613
645
  const plan = await this.readImportPlan(importId);
614
646
  if (!plan) throw new TemplateShareNotFoundError(importId);
615
647
 
648
+ // The recovery deadline is written *before* the record says `applying`.
649
+ // An eviction between the two then leaves an object that is owed a
650
+ // recovery it does not need, which the next pass simply clears; the other
651
+ // order would leave an import mid-apply that nothing is scheduled to
652
+ // finish.
653
+ await this.scheduleImportRecovery();
616
654
  record = await this.patchImport(importId, (current) => ({
617
655
  ...current,
618
656
  status: "applying",
@@ -626,7 +664,7 @@ export class BotTemplateUserBackendContribution {
626
664
  outcome = await this.runImportStep(userId, plan, step, writer);
627
665
  } catch (error) {
628
666
  const failure = error instanceof Error ? error.message : String(error);
629
- return this.patchImport(importId, (current) => ({
667
+ const failed = await this.patchImport(importId, (current) => ({
630
668
  ...current,
631
669
  status: "failed",
632
670
  failure: `${step.key}: ${failure}`,
@@ -636,6 +674,10 @@ export class BotTemplateUserBackendContribution {
636
674
  : entry,
637
675
  ),
638
676
  }));
677
+ // Failed is terminal and visible: the card carries the failure and
678
+ // re-issuing the command retries. Nothing is owed a recovery.
679
+ await this.settleImportRecovery();
680
+ return failed;
639
681
  }
640
682
  record = await this.patchImport(importId, (current) => ({
641
683
  ...current,
@@ -654,10 +696,12 @@ export class BotTemplateUserBackendContribution {
654
696
  ),
655
697
  }));
656
698
  }
657
- return this.patchImport(importId, (current) => ({
699
+ const applied = await this.patchImport(importId, (current) => ({
658
700
  ...current,
659
701
  status: "applied",
660
702
  }));
703
+ await this.settleImportRecovery();
704
+ return applied;
661
705
  }
662
706
 
663
707
  /** Every import left mid-apply, resumed. Called from the User DO's alarm. */
@@ -673,6 +717,45 @@ export class BotTemplateUserBackendContribution {
673
717
  // finish must not stop the other owners of this alarm from running.
674
718
  }
675
719
  }
720
+ // A pass that could not finish an import leaves it `applying`, so the
721
+ // deadline is re-armed here: recovery stays scheduled until every import
722
+ // is terminal, rather than depending on some other owner of this object's
723
+ // alarm to fire again.
724
+ await this.settleImportRecovery();
725
+ }
726
+
727
+ /**
728
+ * Write down that a recovery is owed, and pull the object's alarm forward to
729
+ * the deadline if nothing earlier is already scheduled.
730
+ */
731
+ private async scheduleImportRecovery(): Promise<void> {
732
+ const storage = this.host.storage;
733
+ if (!storage.setAlarm) return;
734
+ const at = this.now() + IMPORT_RECOVERY_DELAY_MS;
735
+ await storage.put(IMPORT_RECOVERY_KEY, at);
736
+ const scheduled = await storage.getAlarm?.();
737
+ if (scheduled === undefined || scheduled === null || scheduled > at) {
738
+ await storage.setAlarm(at);
739
+ }
740
+ }
741
+
742
+ /**
743
+ * Clear the recovery debt, or renew it if any import is still mid-apply.
744
+ *
745
+ * The object's single alarm belongs to every User-scoped owner of one, so
746
+ * this never cancels it: it only records that this Package no longer needs
747
+ * it, and re-arms when it does.
748
+ */
749
+ private async settleImportRecovery(): Promise<void> {
750
+ if (!this.host.storage.setAlarm) return;
751
+ for (const importId of await this.importIndex()) {
752
+ const record = await this.readImport(importId);
753
+ if (record?.status === "applying") {
754
+ await this.scheduleImportRecovery();
755
+ return;
756
+ }
757
+ }
758
+ await this.host.storage.put(IMPORT_RECOVERY_KEY, 0);
676
759
  }
677
760
 
678
761
  private async runImportStep(
@@ -805,7 +888,7 @@ export class BotTemplateUserBackendContribution {
805
888
  );
806
889
  }
807
890
  await transaction.put({
808
- [`${IMPORT_PREFIX}${record.importId}`]: record,
891
+ [templateImportRecordKeyV1(record.importId)]: record,
809
892
  [`${IMPORT_PREFIX}plan:${record.importId}`]: plan,
810
893
  [IMPORT_INDEX_KEY]: [...index, record.importId],
811
894
  });
@@ -818,14 +901,14 @@ export class BotTemplateUserBackendContribution {
818
901
  ): Promise<TemplateImportRecordV1> {
819
902
  return this.host.storage.transaction(async (transaction) => {
820
903
  const stored = await transaction.get<unknown>(
821
- `${IMPORT_PREFIX}${importId}`,
904
+ templateImportRecordKeyV1(importId),
822
905
  );
823
906
  if (stored === undefined) throw new TemplateShareNotFoundError(importId);
824
907
  const next = decodeTemplateImportRecordV1({
825
908
  ...patch(decodeTemplateImportRecordV1(stored)),
826
909
  updatedAt: new Date(this.now()).toISOString(),
827
910
  });
828
- await transaction.put(`${IMPORT_PREFIX}${importId}`, next);
911
+ await transaction.put(templateImportRecordKeyV1(importId), next);
829
912
  return next;
830
913
  });
831
914
  }
@@ -834,7 +917,7 @@ export class BotTemplateUserBackendContribution {
834
917
  importId: string,
835
918
  ): Promise<TemplateImportRecordV1 | undefined> {
836
919
  const stored = await this.host.storage.get<unknown>(
837
- `${IMPORT_PREFIX}${importId}`,
920
+ templateImportRecordKeyV1(importId),
838
921
  );
839
922
  return stored === undefined
840
923
  ? undefined