@frockbot/plugin-bot-template 0.3.23 → 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 +11 -11
- package/src/import-apply.test.ts +100 -2
- package/src/shared.ts +11 -0
- package/src/user.ts +90 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-bot-template",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
28
|
-
"@frockbot/client-ui": "0.3.
|
|
29
|
-
"@frockbot/configuration-core": "0.3.
|
|
30
|
-
"@frockbot/connection-core": "0.3.
|
|
31
|
-
"@frockbot/kernel-agent-loop": "0.3.
|
|
32
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
33
|
-
"@frockbot/plugin-settings": "0.3.
|
|
34
|
-
"@frockbot/plugin-shell": "0.3.
|
|
35
|
-
"@frockbot/template-core": "0.3.
|
|
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.
|
|
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",
|
package/src/import-apply.test.ts
CHANGED
|
@@ -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.
|
|
55
|
+
if (typeof keyOrEntries === "string") this.record(keyOrEntries, value);
|
|
48
56
|
else {
|
|
49
57
|
for (const [key, entry] of Object.entries(keyOrEntries)) {
|
|
50
|
-
this.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
920
|
+
templateImportRecordKeyV1(importId),
|
|
838
921
|
);
|
|
839
922
|
return stored === undefined
|
|
840
923
|
? undefined
|