@frockbot/plugin-shell 0.1.3 → 0.1.4

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/src/shared.ts CHANGED
@@ -7,8 +7,6 @@ import type {
7
7
  BotProfile,
8
8
  BotProfilePatchV1,
9
9
  BotSettingsViewV1,
10
- CapabilityAssignmentView,
11
- ModelAssignment,
12
10
  UserSettingsViewV1,
13
11
  } from "@frockbot/configuration-core";
14
12
  import type {
@@ -206,19 +204,8 @@ export interface FrockBotWebData {
206
204
  namedBy?: BotNameProvenanceV1,
207
205
  ): Promise<void>;
208
206
  saveBotNotifications(notifications: BotNotificationPolicy): Promise<void>;
209
- assignCapability(
210
- assignment: Omit<CapabilityAssignmentView, "state">,
211
- ): Promise<void>;
212
- replaceCapability(
213
- assignment: Omit<CapabilityAssignmentView, "state">,
214
- ): Promise<void>;
215
- unassignCapability(assignmentId: string): Promise<void>;
216
- saveBotModel(model: ModelAssignment): Promise<void>;
217
- clearBotModel(): Promise<void>;
218
207
  loadUserSettings(): Promise<void>;
219
208
  saveUserProfile(profile: { name: string; email?: string }): Promise<void>;
220
- /** The model every Bot uses unless it overrides it. */
221
- saveDefaultModel(model: ModelAssignment | undefined): Promise<void>;
222
209
  loadPluginCatalog(): Promise<void>;
223
210
  /** Refreshes {@link FrockBotWebData.mcpServers}. */
224
211
  loadMcpServers(): Promise<void>;
@@ -1,161 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import {
3
- nextAssignmentPhase,
4
- requireStoredAssignmentSaga,
5
- settleAssignmentSaga,
6
- type AssignmentSagaEffects,
7
- type StoredAssignmentSaga,
8
- } from "./backend-assignment.js";
9
-
10
- function saga(
11
- phase: StoredAssignmentSaga["phase"],
12
- input: Partial<StoredAssignmentSaga> = {},
13
- ): StoredAssignmentSaga {
14
- return {
15
- schemaVersion: 1,
16
- commandId: "command-1",
17
- commandFingerprint: "configuration-command-v1:test",
18
- userId: "user-1",
19
- botId: "bot-1",
20
- operation: "replacing",
21
- assignmentId: "mail",
22
- generation: "generation-1",
23
- phase,
24
- target: {
25
- assignmentId: "mail",
26
- packageId: "mail",
27
- capabilityId: "send",
28
- connectionId: "new-connection",
29
- },
30
- previous: {
31
- assignmentId: "mail",
32
- packageId: "mail",
33
- capabilityId: "send",
34
- connectionId: "old-connection",
35
- state: "enabled",
36
- },
37
- previousGeneration: "old-generation",
38
- deadlineAt: Date.now() + 60_000,
39
- ...input,
40
- acceptedReceipt: input.acceptedReceipt ?? {
41
- schemaVersion: 1,
42
- commandId: "command-1",
43
- revision: 0,
44
- status: "pending",
45
- },
46
- };
47
- }
48
-
49
- function effects(log: string[], acknowledge = true): AssignmentSagaEffects {
50
- return {
51
- acknowledge: () => {
52
- log.push("acknowledge");
53
- return Promise.resolve(acknowledge);
54
- },
55
- compensate: () => {
56
- log.push("compensate");
57
- return Promise.resolve();
58
- },
59
- release: () => {
60
- log.push("release");
61
- return Promise.resolve(true);
62
- },
63
- rejectCommitted: () => {
64
- log.push("reject-committed");
65
- return Promise.resolve();
66
- },
67
- };
68
- }
69
-
70
- describe("Assignment saga transitions", () => {
71
- test("orders Replace as claim, commit, acknowledge, release", () => {
72
- const committed = nextAssignmentPhase(saga("claiming"), "claimed")!;
73
- const acknowledged = nextAssignmentPhase(committed, "committed")!;
74
- const releasing = nextAssignmentPhase(acknowledged, "acknowledged")!;
75
- expect([committed.phase, acknowledged.phase, releasing.phase]).toEqual([
76
- "committing",
77
- "acknowledging",
78
- "releasing",
79
- ]);
80
- expect(nextAssignmentPhase(releasing, "released")).toBeUndefined();
81
- });
82
-
83
- test("finishes a connection-free Assign after commit", () => {
84
- expect(
85
- nextAssignmentPhase(
86
- saga("committing", {
87
- operation: "assigning",
88
- target: {
89
- assignmentId: "clock",
90
- packageId: "clock",
91
- capabilityId: "time",
92
- },
93
- previous: undefined,
94
- previousGeneration: undefined,
95
- }),
96
- "committed",
97
- ),
98
- ).toBeUndefined();
99
- });
100
-
101
- test("strictly decodes durable saga state", () => {
102
- expect(requireStoredAssignmentSaga(saga("claiming"))).toMatchObject({
103
- operation: "replacing",
104
- phase: "claiming",
105
- });
106
- expect(() =>
107
- requireStoredAssignmentSaga({ ...saga("claiming"), extra: true }),
108
- ).toThrow("invalid fields");
109
- const hidden = saga("claiming") as StoredAssignmentSaga & { hidden?: true };
110
- Object.defineProperty(hidden, "hidden", { value: true });
111
- expect(() => requireStoredAssignmentSaga(hidden)).toThrow("invalid fields");
112
- expect(() =>
113
- requireStoredAssignmentSaga({
114
- ...saga("claiming"),
115
- [Symbol("extra")]: true,
116
- }),
117
- ).toThrow("invalid fields");
118
- expect(() =>
119
- requireStoredAssignmentSaga({
120
- ...saga("claiming"),
121
- acceptedReceipt: {
122
- schemaVersion: 1,
123
- commandId: "command-1",
124
- revision: 0,
125
- status: "applied",
126
- },
127
- }),
128
- ).toThrow("accepted receipt is invalid");
129
- });
130
-
131
- test("rejects out-of-order advancement", () => {
132
- expect(() => nextAssignmentPhase(saga("claiming"), "released")).toThrow(
133
- "cannot apply released while claiming",
134
- );
135
- });
136
-
137
- test("compensates a claiming saga and rejects an unacknowledged commit", async () => {
138
- const compensated: string[] = [];
139
- await expect(
140
- settleAssignmentSaga(saga("claiming"), effects(compensated)),
141
- ).resolves.toBe("compensated");
142
- expect(compensated).toEqual(["compensate"]);
143
-
144
- const acknowledged: string[] = [];
145
- await expect(
146
- settleAssignmentSaga(saga("acknowledging"), effects(acknowledged)),
147
- ).resolves.toBe("acknowledged");
148
- expect(acknowledged).toEqual(["acknowledge"]);
149
-
150
- const log: string[] = [];
151
- await expect(
152
- settleAssignmentSaga(saga("acknowledging"), effects(log, false)),
153
- ).resolves.toBe("rejected");
154
- expect(log).toEqual([
155
- "acknowledge",
156
- "compensate",
157
- "release",
158
- "reject-committed",
159
- ]);
160
- });
161
- });
@@ -1,274 +0,0 @@
1
- import {
2
- decodeConnectionDependencyRequirementV1,
3
- decodeModelAssignmentV1,
4
- decodeOperationReceiptV1,
5
- isPublicIdentifier,
6
- type CapabilityAssignmentView,
7
- type ConnectionDependencyRequirementV1,
8
- type ModelAssignment,
9
- type OperationReceiptV1,
10
- } from "@frockbot/configuration-core";
11
-
12
- export type AssignmentOperationKind = "assigning" | "replacing" | "unassigning";
13
-
14
- export type AssignmentSagaPhase =
15
- "claiming" | "committing" | "acknowledging" | "releasing";
16
-
17
- /** Durable Bot-owned coordinator state, separate from its stable Assignment. */
18
- export interface StoredAssignmentSaga {
19
- schemaVersion: 1;
20
- commandId: string;
21
- commandFingerprint: string;
22
- userId: string;
23
- botId: string;
24
- operation: AssignmentOperationKind;
25
- assignmentId: string;
26
- generation: string;
27
- phase: AssignmentSagaPhase;
28
- target?: Omit<CapabilityAssignmentView, "state">;
29
- claimDispatched?: boolean;
30
- acknowledgeDispatched?: boolean;
31
- releaseDispatched?: boolean;
32
- targetRequirement?: ConnectionDependencyRequirementV1;
33
- previous?: CapabilityAssignmentView;
34
- previousGeneration?: string;
35
- /**
36
- * The Bot model this Assignment carries. It commits inside the saga's commit
37
- * phase, so the Connection dependency claim and the model binding are one
38
- * durable, idempotent unit rather than two commands that can disagree.
39
- */
40
- model?: ModelAssignment;
41
- /** The Assignment's removal also unbinds the Bot's model. */
42
- clearModel?: boolean;
43
- deadlineAt: number;
44
- acceptedReceipt: OperationReceiptV1;
45
- receipt?: OperationReceiptV1;
46
- }
47
-
48
- function exact(
49
- value: Record<PropertyKey, unknown>,
50
- required: readonly string[],
51
- optional: readonly string[] = [],
52
- ): void {
53
- const allowed = new Set<PropertyKey>([...required, ...optional]);
54
- if (
55
- !required.every((key) => Object.hasOwn(value, key)) ||
56
- !Reflect.ownKeys(value).every((key) => allowed.has(key))
57
- ) {
58
- throw new Error("Stored Assignment saga has invalid fields");
59
- }
60
- }
61
-
62
- function identifier(value: unknown, label: string): string {
63
- if (typeof value !== "string" || !isPublicIdentifier(value)) {
64
- throw new Error(`Stored Assignment saga ${label} is invalid`);
65
- }
66
- return value;
67
- }
68
-
69
- export function requireStoredAssignmentSaga(
70
- input: unknown,
71
- ): StoredAssignmentSaga {
72
- if (!input || typeof input !== "object" || Array.isArray(input)) {
73
- throw new Error("Stored Assignment saga is invalid");
74
- }
75
- const value = input as Record<PropertyKey, unknown>;
76
- exact(
77
- value,
78
- [
79
- "schemaVersion",
80
- "commandId",
81
- "commandFingerprint",
82
- "userId",
83
- "botId",
84
- "operation",
85
- "assignmentId",
86
- "generation",
87
- "phase",
88
- "deadlineAt",
89
- "acceptedReceipt",
90
- ],
91
- [
92
- "target",
93
- "model",
94
- "clearModel",
95
- "targetRequirement",
96
- "previous",
97
- "previousGeneration",
98
- "claimDispatched",
99
- "acknowledgeDispatched",
100
- "releaseDispatched",
101
- "receipt",
102
- ],
103
- );
104
- if (
105
- value.schemaVersion !== 1 ||
106
- (value.operation !== "assigning" &&
107
- value.operation !== "replacing" &&
108
- value.operation !== "unassigning") ||
109
- (value.phase !== "claiming" &&
110
- value.phase !== "committing" &&
111
- value.phase !== "acknowledging" &&
112
- value.phase !== "releasing") ||
113
- typeof value.deadlineAt !== "number" ||
114
- !Number.isFinite(value.deadlineAt)
115
- ) {
116
- throw new Error("Stored Assignment saga is invalid");
117
- }
118
- for (const key of [
119
- "claimDispatched",
120
- "acknowledgeDispatched",
121
- "releaseDispatched",
122
- "clearModel",
123
- ] as const) {
124
- if (value[key] !== undefined && typeof value[key] !== "boolean") {
125
- throw new Error(`Stored Assignment saga ${key} is invalid`);
126
- }
127
- }
128
- const target = (candidate: unknown, state: boolean) => {
129
- if (
130
- !candidate ||
131
- typeof candidate !== "object" ||
132
- Array.isArray(candidate)
133
- ) {
134
- throw new Error("Stored Assignment saga target is invalid");
135
- }
136
- const item = candidate as Record<PropertyKey, unknown>;
137
- exact(
138
- item,
139
- state
140
- ? ["assignmentId", "packageId", "capabilityId", "state"]
141
- : ["assignmentId", "packageId", "capabilityId"],
142
- ["connectionId"],
143
- );
144
- if (
145
- state &&
146
- item.state !== "enabled" &&
147
- item.state !== "disabled" &&
148
- item.state !== "unavailable"
149
- ) {
150
- throw new Error("Stored Assignment saga target state is invalid");
151
- }
152
- return {
153
- assignmentId: identifier(item.assignmentId, "assignmentId"),
154
- packageId: identifier(item.packageId, "packageId"),
155
- capabilityId: identifier(item.capabilityId, "capabilityId"),
156
- connectionId:
157
- item.connectionId === undefined
158
- ? undefined
159
- : identifier(item.connectionId, "connectionId"),
160
- ...(state
161
- ? { state: item.state as CapabilityAssignmentView["state"] }
162
- : {}),
163
- };
164
- };
165
- const acceptedReceipt = decodeOperationReceiptV1(value.acceptedReceipt);
166
- if (
167
- acceptedReceipt.status !== "pending" ||
168
- acceptedReceipt.commandId !== value.commandId
169
- ) {
170
- throw new Error("Stored Assignment saga accepted receipt is invalid");
171
- }
172
- return {
173
- schemaVersion: 1,
174
- commandId: identifier(value.commandId, "commandId"),
175
- commandFingerprint:
176
- typeof value.commandFingerprint === "string" &&
177
- value.commandFingerprint.length <= 2_000
178
- ? value.commandFingerprint
179
- : (() => {
180
- throw new Error("Stored Assignment saga fingerprint is invalid");
181
- })(),
182
- userId: identifier(value.userId, "userId"),
183
- botId: identifier(value.botId, "botId"),
184
- operation: value.operation,
185
- assignmentId: identifier(value.assignmentId, "assignmentId"),
186
- generation: identifier(value.generation, "generation"),
187
- phase: value.phase,
188
- target:
189
- value.target === undefined
190
- ? undefined
191
- : (target(value.target, false) as Omit<
192
- CapabilityAssignmentView,
193
- "state"
194
- >),
195
- model:
196
- value.model === undefined
197
- ? undefined
198
- : decodeModelAssignmentV1(value.model),
199
- clearModel: value.clearModel as boolean | undefined,
200
- targetRequirement:
201
- value.targetRequirement === undefined
202
- ? undefined
203
- : decodeConnectionDependencyRequirementV1(value.targetRequirement),
204
- previous:
205
- value.previous === undefined
206
- ? undefined
207
- : (target(value.previous, true) as CapabilityAssignmentView),
208
- previousGeneration:
209
- value.previousGeneration === undefined
210
- ? undefined
211
- : identifier(value.previousGeneration, "previousGeneration"),
212
- claimDispatched: value.claimDispatched as boolean | undefined,
213
- acknowledgeDispatched: value.acknowledgeDispatched as boolean | undefined,
214
- releaseDispatched: value.releaseDispatched as boolean | undefined,
215
- deadlineAt: value.deadlineAt,
216
- acceptedReceipt,
217
- receipt:
218
- value.receipt === undefined
219
- ? undefined
220
- : decodeOperationReceiptV1(value.receipt),
221
- };
222
- }
223
-
224
- export function nextAssignmentPhase(
225
- saga: StoredAssignmentSaga,
226
- event: "claimed" | "committed" | "acknowledged" | "released",
227
- ): StoredAssignmentSaga | undefined {
228
- if (saga.phase === "claiming" && event === "claimed") {
229
- return { ...saga, phase: "committing" };
230
- }
231
- if (saga.phase === "committing" && event === "committed") {
232
- if (saga.target?.connectionId) {
233
- return { ...saga, phase: "acknowledging" };
234
- }
235
- if (saga.previous?.connectionId) {
236
- return { ...saga, phase: "releasing" };
237
- }
238
- return undefined;
239
- }
240
- if (saga.phase === "acknowledging" && event === "acknowledged") {
241
- return saga.previous?.connectionId
242
- ? { ...saga, phase: "releasing" }
243
- : undefined;
244
- }
245
- if (saga.phase === "releasing" && event === "released") return undefined;
246
- throw new Error(`Assignment saga cannot apply ${event} while ${saga.phase}`);
247
- }
248
-
249
- export type AssignmentSagaSettlement =
250
- "acknowledged" | "compensated" | "rejected";
251
-
252
- export interface AssignmentSagaEffects {
253
- acknowledge(saga: StoredAssignmentSaga): Promise<boolean>;
254
- compensate(saga: StoredAssignmentSaga): Promise<void>;
255
- release(saga: StoredAssignmentSaga): Promise<boolean>;
256
- rejectCommitted(saga: StoredAssignmentSaga): Promise<void>;
257
- }
258
-
259
- export async function settleAssignmentSaga(
260
- saga: StoredAssignmentSaga,
261
- effects: AssignmentSagaEffects,
262
- ): Promise<AssignmentSagaSettlement> {
263
- if (saga.phase === "claiming") {
264
- await effects.compensate(saga);
265
- return "compensated";
266
- }
267
- if (await effects.acknowledge(saga)) return "acknowledged";
268
- await effects.compensate(saga);
269
- if (!(await effects.release(saga))) {
270
- throw new Error("Rejected Connection dependency was not released");
271
- }
272
- await effects.rejectCommitted(saga);
273
- return "rejected";
274
- }