@frockbot/plugin-user-machine 0.0.0 → 0.1.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/frockbot.json +66 -0
- package/package.json +54 -6
- package/src/agent.test.ts +509 -0
- package/src/agent.ts +733 -0
- package/src/approval.test.ts +323 -0
- package/src/approval.ts +148 -0
- package/src/backend.test.ts +370 -0
- package/src/backend.ts +370 -0
- package/src/client/MachineSection.vue +97 -0
- package/src/client/MachineSurface.vue +308 -0
- package/src/client/index.test.ts +244 -0
- package/src/client/index.ts +180 -0
- package/src/client/state.ts +49 -0
- package/src/delivery.ts +145 -0
- package/src/desktop.test.ts +233 -0
- package/src/desktop.ts +238 -0
- package/src/device-runner.test.ts +326 -0
- package/src/device-runner.ts +205 -0
- package/src/device.test.ts +418 -0
- package/src/device.ts +707 -0
- package/src/env.d.ts +6 -0
- package/src/index.ts +13 -0
- package/src/intent.ts +325 -0
- package/src/manifest.ts +3 -0
- package/src/pairing.test.ts +85 -0
- package/src/pairing.ts +182 -0
- package/src/storage-keys.ts +102 -0
- package/src/store.test.ts +507 -0
- package/src/store.ts +638 -0
- package/src/target.ts +86 -0
- package/src/testing.ts +352 -0
- package/src/user.test.ts +182 -0
- package/src/user.ts +442 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// The intent record and the settlement that reads it.
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
import type { MachineCommandV1 } from "@frockbot/machine-protocol";
|
|
4
|
+
import {
|
|
5
|
+
decodeMachineIntentRecordV1,
|
|
6
|
+
dispatchedMachineIntentV1,
|
|
7
|
+
machineApprovalActionV1,
|
|
8
|
+
machineCommandForIntentV1,
|
|
9
|
+
machineIntentKeyV1,
|
|
10
|
+
settledMachineIntentV1,
|
|
11
|
+
MACHINE_INTENT_PREFIX,
|
|
12
|
+
type MachineIntentRecordV1,
|
|
13
|
+
} from "./intent.js";
|
|
14
|
+
import {
|
|
15
|
+
decodeMachineDispatchAnswerV1,
|
|
16
|
+
dispatchMachineIntentV1,
|
|
17
|
+
settleMachineIntentV1,
|
|
18
|
+
type MachineDispatchAnswerV1,
|
|
19
|
+
} from "./approval.js";
|
|
20
|
+
import {
|
|
21
|
+
machineResultDeliveryV1,
|
|
22
|
+
machineResultPreviewV1,
|
|
23
|
+
decodeMachineResultDeliveryV1,
|
|
24
|
+
} from "./delivery.js";
|
|
25
|
+
|
|
26
|
+
const NOW = "2026-09-01T00:00:00.000Z";
|
|
27
|
+
const LATER = "2026-09-01T00:05:00.000Z";
|
|
28
|
+
|
|
29
|
+
function intent(
|
|
30
|
+
overrides: Partial<MachineIntentRecordV1> = {},
|
|
31
|
+
): MachineIntentRecordV1 {
|
|
32
|
+
return {
|
|
33
|
+
schemaVersion: 1,
|
|
34
|
+
approvalId: "tool:4:2:0",
|
|
35
|
+
commandId: "tool:4:2:0",
|
|
36
|
+
machineId: "mac-1",
|
|
37
|
+
botId: "bot-1",
|
|
38
|
+
runId: "run-1",
|
|
39
|
+
turn: 4,
|
|
40
|
+
op: {
|
|
41
|
+
kind: "exec",
|
|
42
|
+
command: "git status",
|
|
43
|
+
timeoutMs: 60_000,
|
|
44
|
+
maxOutputBytes: 1_024,
|
|
45
|
+
},
|
|
46
|
+
createdAt: NOW,
|
|
47
|
+
...overrides,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function storage(seed: Record<string, unknown> = {}) {
|
|
52
|
+
const map = new Map<string, unknown>(Object.entries(seed));
|
|
53
|
+
return {
|
|
54
|
+
map,
|
|
55
|
+
get: async <T>(key: string) => map.get(key) as T | undefined,
|
|
56
|
+
put: async (key: string, value: unknown) => {
|
|
57
|
+
map.set(key, value);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("the intent record", () => {
|
|
63
|
+
test("keys on the approval id, and round-trips exactly", () => {
|
|
64
|
+
expect(machineIntentKeyV1("ap-1")).toBe(`${MACHINE_INTENT_PREFIX}ap-1`);
|
|
65
|
+
const record = intent();
|
|
66
|
+
expect(decodeMachineIntentRecordV1(record)).toEqual(record);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("refuses a field it does not declare and a turn that is not one", () => {
|
|
70
|
+
expect(() =>
|
|
71
|
+
decodeMachineIntentRecordV1({ ...intent(), stdout: "…" }),
|
|
72
|
+
).toThrow('unexpected key "stdout"');
|
|
73
|
+
expect(() =>
|
|
74
|
+
decodeMachineIntentRecordV1({ ...intent(), turn: -1 }),
|
|
75
|
+
).toThrow("turn is invalid");
|
|
76
|
+
expect(() =>
|
|
77
|
+
decodeMachineIntentRecordV1({ ...intent(), outcome: "maybe" }),
|
|
78
|
+
).toThrow("outcome is invalid");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("the command it dispatches carries the same id, which is the effect's", () => {
|
|
82
|
+
const command: MachineCommandV1 = machineCommandForIntentV1(
|
|
83
|
+
intent(),
|
|
84
|
+
LATER,
|
|
85
|
+
);
|
|
86
|
+
expect(command).toEqual({
|
|
87
|
+
schemaVersion: 1,
|
|
88
|
+
commandId: "tool:4:2:0",
|
|
89
|
+
machineId: "mac-1",
|
|
90
|
+
botId: "bot-1",
|
|
91
|
+
runId: "run-1",
|
|
92
|
+
turn: 4,
|
|
93
|
+
approvalId: "tool:4:2:0",
|
|
94
|
+
op: intent().op,
|
|
95
|
+
issuedAt: LATER,
|
|
96
|
+
status: "queued",
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("the card names the machine and what is about to happen on it", () => {
|
|
101
|
+
expect(machineApprovalActionV1(intent().op, "Tims-Mac")).toBe(
|
|
102
|
+
"Run on Tims-Mac: git status",
|
|
103
|
+
);
|
|
104
|
+
expect(
|
|
105
|
+
machineApprovalActionV1(
|
|
106
|
+
{ kind: "read", path: "/etc/hosts", maxBytes: 10 },
|
|
107
|
+
"Tims-Mac",
|
|
108
|
+
),
|
|
109
|
+
).toBe("Read /etc/hosts from Tims-Mac");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("settling an intent inside the deciding transaction", () => {
|
|
114
|
+
test("an approval that is not a machine command's is nobody's business", async () => {
|
|
115
|
+
const store = storage();
|
|
116
|
+
expect(
|
|
117
|
+
await settleMachineIntentV1(store, "ap-other", "approved", LATER),
|
|
118
|
+
).toBeUndefined();
|
|
119
|
+
expect(store.map.size).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("records the decision beside the command it authorized", async () => {
|
|
123
|
+
const store = storage({ [machineIntentKeyV1("tool:4:2:0")]: intent() });
|
|
124
|
+
const settled = await settleMachineIntentV1(
|
|
125
|
+
store,
|
|
126
|
+
"tool:4:2:0",
|
|
127
|
+
"approved",
|
|
128
|
+
LATER,
|
|
129
|
+
);
|
|
130
|
+
expect(settled?.decision).toBe("approved");
|
|
131
|
+
expect(settled?.decidedAt).toBe(LATER);
|
|
132
|
+
// Approved is not yet terminal: the queue has not answered.
|
|
133
|
+
expect(settled?.outcome).toBeUndefined();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("a denial and an expiry are terminal where they stand", async () => {
|
|
137
|
+
for (const answer of ["denied", "expired"] as const) {
|
|
138
|
+
const store = storage({ [machineIntentKeyV1("tool:4:2:0")]: intent() });
|
|
139
|
+
const settled = await settleMachineIntentV1(
|
|
140
|
+
store,
|
|
141
|
+
"tool:4:2:0",
|
|
142
|
+
answer,
|
|
143
|
+
LATER,
|
|
144
|
+
);
|
|
145
|
+
expect(settled?.decision).toBe(answer);
|
|
146
|
+
expect(settled?.outcome).toBe(answer);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("first write wins: an alarm racing a person changes nothing", async () => {
|
|
151
|
+
const decided = intent({ decision: "approved", decidedAt: LATER });
|
|
152
|
+
const store = storage({ [machineIntentKeyV1("tool:4:2:0")]: decided });
|
|
153
|
+
const settled = await settleMachineIntentV1(
|
|
154
|
+
store,
|
|
155
|
+
"tool:4:2:0",
|
|
156
|
+
"expired",
|
|
157
|
+
"2026-09-01T01:00:00.000Z",
|
|
158
|
+
);
|
|
159
|
+
expect(settled).toEqual(decided);
|
|
160
|
+
expect(store.map.get(machineIntentKeyV1("tool:4:2:0"))).toEqual(decided);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("a record this Package cannot read does not fail a person's decision", async () => {
|
|
164
|
+
const store = storage({ [machineIntentKeyV1("tool:4:2:0")]: { nope: 1 } });
|
|
165
|
+
expect(
|
|
166
|
+
await settleMachineIntentV1(store, "tool:4:2:0", "approved", LATER),
|
|
167
|
+
).toBeUndefined();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("pure: the settled record is a function of its arguments", () => {
|
|
171
|
+
const before = intent();
|
|
172
|
+
const first = settledMachineIntentV1(before, "approved", LATER);
|
|
173
|
+
const second = settledMachineIntentV1(before, "approved", LATER);
|
|
174
|
+
expect(first).toEqual(second);
|
|
175
|
+
expect(before.decision).toBeUndefined();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("dispatching an approved intent", () => {
|
|
180
|
+
const queued: MachineDispatchAnswerV1 = {
|
|
181
|
+
status: "queued",
|
|
182
|
+
command: machineCommandForIntentV1(intent(), LATER),
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
test("queues exactly once and records that it did", async () => {
|
|
186
|
+
const store = storage();
|
|
187
|
+
const seen: MachineCommandV1[] = [];
|
|
188
|
+
const settled = await dispatchMachineIntentV1(
|
|
189
|
+
store,
|
|
190
|
+
intent({ decision: "approved", decidedAt: LATER }),
|
|
191
|
+
async (command) => {
|
|
192
|
+
seen.push(command);
|
|
193
|
+
return queued;
|
|
194
|
+
},
|
|
195
|
+
LATER,
|
|
196
|
+
);
|
|
197
|
+
expect(seen).toHaveLength(1);
|
|
198
|
+
expect(seen[0]!.commandId).toBe("tool:4:2:0");
|
|
199
|
+
expect(settled.outcome).toBe("dispatched");
|
|
200
|
+
expect(settled.dispatchedAt).toBe(LATER);
|
|
201
|
+
expect(store.map.get(machineIntentKeyV1("tool:4:2:0"))).toEqual(settled);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("a denial, an expiry and an already-answered intent dispatch nothing", async () => {
|
|
205
|
+
for (const already of [
|
|
206
|
+
intent({ decision: "denied", outcome: "denied" }),
|
|
207
|
+
intent({ decision: "expired", outcome: "expired" }),
|
|
208
|
+
intent({ decision: "approved", outcome: "dispatched" }),
|
|
209
|
+
intent(),
|
|
210
|
+
]) {
|
|
211
|
+
const store = storage();
|
|
212
|
+
let called = false;
|
|
213
|
+
const settled = await dispatchMachineIntentV1(
|
|
214
|
+
store,
|
|
215
|
+
already,
|
|
216
|
+
async () => {
|
|
217
|
+
called = true;
|
|
218
|
+
return queued;
|
|
219
|
+
},
|
|
220
|
+
LATER,
|
|
221
|
+
);
|
|
222
|
+
expect(called).toBe(false);
|
|
223
|
+
expect(settled).toEqual(already);
|
|
224
|
+
expect(store.map.size).toBe(0);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("a queue that refuses is recorded as refused, with the reason", async () => {
|
|
229
|
+
const store = storage();
|
|
230
|
+
const settled = await dispatchMachineIntentV1(
|
|
231
|
+
store,
|
|
232
|
+
intent({ decision: "approved" }),
|
|
233
|
+
async () => ({
|
|
234
|
+
status: "refused",
|
|
235
|
+
reason: "Refused: this machine is not registered.",
|
|
236
|
+
}),
|
|
237
|
+
LATER,
|
|
238
|
+
);
|
|
239
|
+
expect(settled.outcome).toBe("refused");
|
|
240
|
+
expect(settled.dispatchedAt).toBeUndefined();
|
|
241
|
+
expect(settled.reason).toContain("not registered");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("a duplicate is recorded as one, never as a second command", async () => {
|
|
245
|
+
const store = storage();
|
|
246
|
+
const settled = await dispatchMachineIntentV1(
|
|
247
|
+
store,
|
|
248
|
+
intent({ decision: "approved" }),
|
|
249
|
+
async () => ({ ...queued, status: "duplicate" as const }),
|
|
250
|
+
LATER,
|
|
251
|
+
);
|
|
252
|
+
expect(settled.outcome).toBe("duplicate");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("the answer is decoded at the seam it crosses", () => {
|
|
256
|
+
expect(decodeMachineDispatchAnswerV1(queued)).toEqual(queued);
|
|
257
|
+
expect(
|
|
258
|
+
decodeMachineDispatchAnswerV1({ status: "refused", reason: "no" }),
|
|
259
|
+
).toEqual({ status: "refused", reason: "no" });
|
|
260
|
+
expect(() => decodeMachineDispatchAnswerV1({ status: "maybe" })).toThrow(
|
|
261
|
+
"status is invalid",
|
|
262
|
+
);
|
|
263
|
+
expect(() =>
|
|
264
|
+
decodeMachineDispatchAnswerV1({ ...queued, extra: 1 }),
|
|
265
|
+
).toThrow('unexpected key "extra"');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("the dispatched record is pure in its arguments", () => {
|
|
269
|
+
const before = intent({ decision: "approved" });
|
|
270
|
+
expect(dispatchedMachineIntentV1(before, "dispatched", LATER)).toEqual(
|
|
271
|
+
dispatchedMachineIntentV1(before, "dispatched", LATER),
|
|
272
|
+
);
|
|
273
|
+
expect(before.outcome).toBeUndefined();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe("the delivery back to the Bot", () => {
|
|
278
|
+
const command = machineCommandForIntentV1(intent(), LATER);
|
|
279
|
+
|
|
280
|
+
test("carries a preview and never the output", () => {
|
|
281
|
+
const delivery = machineResultDeliveryV1(command, {
|
|
282
|
+
schemaVersion: 1,
|
|
283
|
+
commandId: "tool:4:2:0",
|
|
284
|
+
finishedAt: LATER,
|
|
285
|
+
outcome: "ok",
|
|
286
|
+
truncated: true,
|
|
287
|
+
exitCode: 0,
|
|
288
|
+
stdout: "x".repeat(10_000),
|
|
289
|
+
});
|
|
290
|
+
expect(delivery.botId).toBe("bot-1");
|
|
291
|
+
expect(delivery.preview.length).toBeLessThanOrEqual(400);
|
|
292
|
+
expect(delivery.preview).toStartWith("exit 0 — ");
|
|
293
|
+
expect(decodeMachineResultDeliveryV1(delivery)).toEqual(delivery);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("a result with nothing to say still says its outcome", () => {
|
|
297
|
+
expect(
|
|
298
|
+
machineResultPreviewV1({
|
|
299
|
+
schemaVersion: 1,
|
|
300
|
+
commandId: "c",
|
|
301
|
+
finishedAt: LATER,
|
|
302
|
+
outcome: "timeout",
|
|
303
|
+
truncated: false,
|
|
304
|
+
}),
|
|
305
|
+
).toBe("timeout");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("refuses a delivery that carries a field it does not declare", () => {
|
|
309
|
+
const delivery = machineResultDeliveryV1(command, {
|
|
310
|
+
schemaVersion: 1,
|
|
311
|
+
commandId: "tool:4:2:0",
|
|
312
|
+
finishedAt: LATER,
|
|
313
|
+
outcome: "error",
|
|
314
|
+
truncated: false,
|
|
315
|
+
});
|
|
316
|
+
expect(() =>
|
|
317
|
+
decodeMachineResultDeliveryV1({ ...delivery, stdout: "…" }),
|
|
318
|
+
).toThrow('unexpected key "stdout"');
|
|
319
|
+
expect(() =>
|
|
320
|
+
decodeMachineResultDeliveryV1({ ...delivery, outcome: "maybe" }),
|
|
321
|
+
).toThrow("outcome is invalid");
|
|
322
|
+
});
|
|
323
|
+
});
|
package/src/approval.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// The approval settlement, as far as this Package owns it.
|
|
2
|
+
//
|
|
3
|
+
// `plugin-shell` owns the approval record, its expiry alarm and the route a
|
|
4
|
+
// person answers on; it has done since row 53 landed, and this slice adds no
|
|
5
|
+
// second approval mechanism. What it adds is two functions the settlement
|
|
6
|
+
// calls:
|
|
7
|
+
//
|
|
8
|
+
// * `settleMachineIntentV1` runs *inside* the transaction that records the
|
|
9
|
+
// decision. The decision and what it authorized become durable together, so
|
|
10
|
+
// there is no instant at which a person has approved something whose intent
|
|
11
|
+
// record still says nobody answered.
|
|
12
|
+
//
|
|
13
|
+
// * `dispatchMachineIntentV1` runs *after* it. A cross-Durable-Object call
|
|
14
|
+
// inside a storage transaction would make the transaction's atomicity a
|
|
15
|
+
// lie, and it does not need to be inside one: the dispatch is idempotent on
|
|
16
|
+
// `commandId`, so a crash between the commit and the RPC is a retry and
|
|
17
|
+
// never a second command on somebody's laptop.
|
|
18
|
+
//
|
|
19
|
+
// A denial or an expiry dispatches nothing and terminates the intent where it
|
|
20
|
+
// stands. Only `approved` reaches a queue.
|
|
21
|
+
import {
|
|
22
|
+
MACHINE_LIMITS_V1,
|
|
23
|
+
MachineDecodeError,
|
|
24
|
+
decodeMachineCommandV1,
|
|
25
|
+
type MachineCommandV1,
|
|
26
|
+
} from "@frockbot/machine-protocol";
|
|
27
|
+
import type { MachineIntentStorageV1 } from "./agent.js";
|
|
28
|
+
import {
|
|
29
|
+
decodeMachineIntentRecordV1,
|
|
30
|
+
dispatchedMachineIntentV1,
|
|
31
|
+
machineCommandForIntentV1,
|
|
32
|
+
machineIntentKeyV1,
|
|
33
|
+
settledMachineIntentV1,
|
|
34
|
+
type MachineIntentRecordV1,
|
|
35
|
+
} from "./intent.js";
|
|
36
|
+
|
|
37
|
+
/** What the queue answered a dispatch with. `store.ts`'s outcome, narrowed. */
|
|
38
|
+
export type MachineDispatchAnswerV1 =
|
|
39
|
+
| { status: "queued"; command: MachineCommandV1 }
|
|
40
|
+
| { status: "duplicate"; command: MachineCommandV1 }
|
|
41
|
+
| { status: "refused"; reason: string };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The dispatch answer, decoded at the seam it crosses.
|
|
45
|
+
*
|
|
46
|
+
* It comes back from another Durable Object, so it is decoded rather than
|
|
47
|
+
* trusted in the shape RPC happened to return — the same rule every other
|
|
48
|
+
* value in this Package's protocol is held to.
|
|
49
|
+
*/
|
|
50
|
+
export function decodeMachineDispatchAnswerV1(
|
|
51
|
+
input: unknown,
|
|
52
|
+
label = "machine dispatch answer",
|
|
53
|
+
): MachineDispatchAnswerV1 {
|
|
54
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
55
|
+
throw new MachineDecodeError(`${label} must be an object`);
|
|
56
|
+
}
|
|
57
|
+
const value = input as Record<string, unknown>;
|
|
58
|
+
if (value.status === "refused") {
|
|
59
|
+
for (const key of Object.keys(value)) {
|
|
60
|
+
if (!["status", "reason"].includes(key)) {
|
|
61
|
+
throw new MachineDecodeError(`${label} has an unexpected key "${key}"`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (
|
|
65
|
+
typeof value.reason !== "string" ||
|
|
66
|
+
value.reason.length === 0 ||
|
|
67
|
+
value.reason.length > MACHINE_LIMITS_V1.message
|
|
68
|
+
) {
|
|
69
|
+
throw new MachineDecodeError(`${label} reason is invalid`);
|
|
70
|
+
}
|
|
71
|
+
return { status: "refused", reason: value.reason };
|
|
72
|
+
}
|
|
73
|
+
if (value.status !== "queued" && value.status !== "duplicate") {
|
|
74
|
+
throw new MachineDecodeError(`${label} status is invalid`);
|
|
75
|
+
}
|
|
76
|
+
for (const key of Object.keys(value)) {
|
|
77
|
+
if (!["status", "command"].includes(key)) {
|
|
78
|
+
throw new MachineDecodeError(`${label} has an unexpected key "${key}"`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
status: value.status,
|
|
83
|
+
command: decodeMachineCommandV1(value.command, `${label} command`),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Record what a decision means for the machine command it authorized, in the
|
|
89
|
+
* transaction that records the decision itself.
|
|
90
|
+
*
|
|
91
|
+
* Answers `undefined` when the approval was not a machine command's — the
|
|
92
|
+
* common case, since most cards are not this Package's — so the caller can
|
|
93
|
+
* treat "not ours" and "nothing to do" identically.
|
|
94
|
+
*/
|
|
95
|
+
export async function settleMachineIntentV1(
|
|
96
|
+
transaction: MachineIntentStorageV1,
|
|
97
|
+
approvalId: string,
|
|
98
|
+
decision: "approved" | "denied" | "expired",
|
|
99
|
+
at: string,
|
|
100
|
+
): Promise<MachineIntentRecordV1 | undefined> {
|
|
101
|
+
const key = machineIntentKeyV1(approvalId);
|
|
102
|
+
const stored = await transaction.get<unknown>(key);
|
|
103
|
+
if (stored === undefined) return undefined;
|
|
104
|
+
let intent: MachineIntentRecordV1;
|
|
105
|
+
try {
|
|
106
|
+
intent = decodeMachineIntentRecordV1(stored, "stored machine intent");
|
|
107
|
+
} catch (error) {
|
|
108
|
+
// A record this Package cannot read is not a reason a person's decision
|
|
109
|
+
// fails to record. It is left exactly as it is and reported nowhere but
|
|
110
|
+
// here, where the settlement can carry on.
|
|
111
|
+
if (error instanceof MachineDecodeError) return undefined;
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
const settled = settledMachineIntentV1(intent, decision, at);
|
|
115
|
+
if (settled !== intent) await transaction.put(key, settled);
|
|
116
|
+
return settled;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Put an approved intent's command on its machine's queue, and record what the
|
|
121
|
+
* queue said.
|
|
122
|
+
*
|
|
123
|
+
* Called after the settling transaction has committed. `dispatch` is the
|
|
124
|
+
* narrow User-Durable-Object seam; nothing here knows it is an RPC.
|
|
125
|
+
*/
|
|
126
|
+
export async function dispatchMachineIntentV1(
|
|
127
|
+
storage: MachineIntentStorageV1,
|
|
128
|
+
intent: MachineIntentRecordV1,
|
|
129
|
+
dispatch: (command: MachineCommandV1) => Promise<MachineDispatchAnswerV1>,
|
|
130
|
+
at: string,
|
|
131
|
+
): Promise<MachineIntentRecordV1> {
|
|
132
|
+
// Only an approved intent that has not already been answered by the queue
|
|
133
|
+
// reaches a machine. A replayed settlement stops here.
|
|
134
|
+
if (intent.decision !== "approved" || intent.outcome !== undefined) {
|
|
135
|
+
return intent;
|
|
136
|
+
}
|
|
137
|
+
const answer = await dispatch(machineCommandForIntentV1(intent, at));
|
|
138
|
+
const settled =
|
|
139
|
+
answer.status === "refused"
|
|
140
|
+
? dispatchedMachineIntentV1(intent, "refused", at, answer.reason)
|
|
141
|
+
: dispatchedMachineIntentV1(
|
|
142
|
+
intent,
|
|
143
|
+
answer.status === "queued" ? "dispatched" : "duplicate",
|
|
144
|
+
at,
|
|
145
|
+
);
|
|
146
|
+
await storage.put(machineIntentKeyV1(intent.approvalId), settled);
|
|
147
|
+
return settled;
|
|
148
|
+
}
|