@loopbacktools/server 0.0.0-stage → 0.4.1

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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -2
  3. package/dist/package.json +57 -0
  4. package/dist/src/domain/ask.d.ts +78 -0
  5. package/dist/src/domain/ask.js +161 -0
  6. package/dist/src/domain/errors.d.ts +70 -0
  7. package/dist/src/domain/errors.js +57 -0
  8. package/dist/src/generated/app.gen.d.ts +2311 -0
  9. package/dist/src/generated/app.gen.js +400 -0
  10. package/dist/src/generated/runtime.gen.d.ts +357 -0
  11. package/dist/src/generated/runtime.gen.js +298 -0
  12. package/dist/src/generated/schemas.gen.d.ts +6416 -0
  13. package/dist/src/generated/schemas.gen.js +306 -0
  14. package/dist/src/generated/wire-contract.gen.d.ts +77 -0
  15. package/dist/src/generated/wire-contract.gen.js +3 -0
  16. package/dist/src/index.d.ts +11 -0
  17. package/dist/src/index.js +8 -0
  18. package/dist/src/loopback.d.ts +27 -0
  19. package/dist/src/loopback.js +16 -0
  20. package/dist/src/space/arguments.d.ts +8 -0
  21. package/dist/src/space/arguments.js +52 -0
  22. package/dist/src/space/migrations.d.ts +10 -0
  23. package/dist/src/space/migrations.js +106 -0
  24. package/dist/src/space/object.d.ts +30 -0
  25. package/dist/src/space/object.js +169 -0
  26. package/dist/src/space/operations.d.ts +149 -0
  27. package/dist/src/space/operations.js +568 -0
  28. package/dist/src/space/read.d.ts +8 -0
  29. package/dist/src/space/read.js +33 -0
  30. package/dist/src/space/rpc.d.ts +61 -0
  31. package/dist/src/space/rpc.js +96 -0
  32. package/dist/src/space/services.d.ts +41 -0
  33. package/dist/src/space/services.js +58 -0
  34. package/dist/src/space/store.d.ts +87 -0
  35. package/dist/src/space/store.js +276 -0
  36. package/dist/src/version.d.ts +2 -0
  37. package/dist/src/version.js +3 -0
  38. package/dist/src/worker/env.d.ts +19 -0
  39. package/dist/src/worker/env.js +3 -0
  40. package/dist/src/worker/files.d.ts +21 -0
  41. package/dist/src/worker/files.js +42 -0
  42. package/dist/src/worker/gate.d.ts +55 -0
  43. package/dist/src/worker/gate.js +83 -0
  44. package/dist/src/worker/http.d.ts +2092 -0
  45. package/dist/src/worker/http.js +291 -0
  46. package/dist/src/worker/jwt.d.ts +49 -0
  47. package/dist/src/worker/jwt.js +142 -0
  48. package/dist/src/worker/mcp.d.ts +15 -0
  49. package/dist/src/worker/mcp.js +401 -0
  50. package/dist/src/worker/run.d.ts +45 -0
  51. package/dist/src/worker/run.js +71 -0
  52. package/dist/src/worker/spaces.d.ts +24 -0
  53. package/dist/src/worker/spaces.js +64 -0
  54. package/package.json +51 -3
@@ -0,0 +1,568 @@
1
+ import { answerEntries, answersProblem, foldBriefing, } from "@loopbacktools/protocol";
2
+ import { Clock, Duration, Effect, Option, PubSub } from "effect";
3
+ import { briefingPrompt, checkReply, checkSize, fingerprint, imagesIn, questionPrompt, REATTACH_WINDOW_MS, reviewPrompt, sha256, statusAfter, toAsk, } from "../domain/ask.js";
4
+ import { AskClosed, AskNotFound, ContentInvalid, NotYourTurn, TicketInvalid, UploadExpired, UploadNotFound, UploadRejected, } from "../domain/errors.js";
5
+ import { Host, MAX_WAIT_MS, Presence, Replies } from "./services.js";
6
+ import { AskStore, TicketStore, UploadStore } from "./store.js";
7
+ /** How many closed asks a snapshot carries. */
8
+ export const RECENT_LIMIT = 50;
9
+ /** How long an upload ticket stays usable. */
10
+ export const UPLOAD_TTL_MS = 15 * 60 * 1000;
11
+ /** How long an event ticket stays usable: long enough to open a socket, too short to be worth stealing. */
12
+ export const TICKET_TTL_MS = 60 * 1000;
13
+ /** The largest image an upload ticket accepts. */
14
+ export const UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
15
+ const now = Effect.map(Clock.currentTimeMillis, (ms) => new Date(ms).toISOString());
16
+ /** A fresh 256-bit secret, URL- and subprotocol-safe. */
17
+ const secret = () => btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32))))
18
+ .replaceAll("+", "-")
19
+ .replaceAll("/", "_")
20
+ .replace(/=+$/, "");
21
+ const findAsk = Effect.fnUntraced(function* (askId) {
22
+ const store = yield* AskStore;
23
+ const found = yield* store.find(askId);
24
+ if (Option.isNone(found))
25
+ return yield* new AskNotFound({ askId });
26
+ return found.value;
27
+ });
28
+ /** How often an agent's waiting is written down: often enough for the reattach window, no more. */
29
+ const NOTE_WAITING_EVERY_MS = 60_000;
30
+ /** Writes down that an agent is waiting on the ask, unless that was done within the last minute. */
31
+ const noteWaiting = Effect.fnUntraced(function* (record, at) {
32
+ const since = record.lastWaitedAt === null ? Infinity : Date.parse(at) - Date.parse(record.lastWaitedAt);
33
+ if (since < NOTE_WAITING_EVERY_MS)
34
+ return;
35
+ const store = yield* AskStore;
36
+ yield* store.touchWaited(record.id, at);
37
+ });
38
+ /** The ask in its wire shape, with its thread and whether an agent is waiting on it. */
39
+ const view = Effect.fnUntraced(function* (record) {
40
+ const store = yield* AskStore;
41
+ const presence = yield* Presence;
42
+ return toAsk(record, yield* store.thread(record.id), presence.isWaiting(record.id));
43
+ });
44
+ /** The asks in their wire shape, leaving out any whose thread can't be read. */
45
+ const views = Effect.fnUntraced(function* (records) {
46
+ const store = yield* AskStore;
47
+ const presence = yield* Presence;
48
+ const threads = yield* store.threads(records.map((record) => record.id));
49
+ return records.flatMap((record) => {
50
+ const thread = threads.get(record.id);
51
+ return thread === undefined ? [] : [toAsk(record, thread, presence.isWaiting(record.id))];
52
+ });
53
+ });
54
+ const announce = Effect.fnUntraced(function* (type, ask) {
55
+ const host = yield* Host;
56
+ yield* host.broadcast({ type, ask });
57
+ });
58
+ /** Every inbox hears the ask as it now stands, since an agent started or stopped following it. */
59
+ const announceFollowing = (askId) => Effect.flatMap(findAsk(askId), view).pipe(Effect.flatMap((ask) => announce("ask.updated", ask)), Effect.ignoreCause({ log: true }));
60
+ /** A lap or poll begins on the ask; inboxes hear it when that starts the ask being followed. */
61
+ const follow = Effect.fnUntraced(function* (askId) {
62
+ const presence = yield* Presence;
63
+ if (yield* presence.begin(askId))
64
+ yield* announceFollowing(askId);
65
+ });
66
+ /** A lap or poll ends; inboxes hear it if no other begins within the grace. */
67
+ const unfollow = Effect.fnUntraced(function* (askId) {
68
+ const presence = yield* Presence;
69
+ yield* presence.end(askId, announceFollowing(askId));
70
+ });
71
+ /** Every image the blocks show must be an upload this space holds. */
72
+ const checkImages = Effect.fnUntraced(function* (blocks) {
73
+ const uploads = yield* UploadStore;
74
+ for (const uploadId of imagesIn(blocks)) {
75
+ const upload = yield* uploads.find(uploadId);
76
+ if (Option.isNone(upload) || upload.value.status !== "stored") {
77
+ return yield* new ContentInvalid({
78
+ reason: `No uploaded image "${uploadId}". Upload it with create_image_upload first.`,
79
+ });
80
+ }
81
+ }
82
+ });
83
+ /** Everything a set of briefing points shows. */
84
+ const blocksOf = (points) => points.flatMap((point) => point.content ?? []);
85
+ /**
86
+ * Opens an ask, or rejoins the one a retried call opened. A retry is the same question or review,
87
+ * from the same client and project, while the ask is still open or holds a reply that never
88
+ * reached the agent, and only if an agent waited on it within the reattach window.
89
+ */
90
+ export const open = Effect.fn("Space.open")(function* (request) {
91
+ const store = yield* AskStore;
92
+ yield* checkSize(request.input, `Show the part that matters, or split it into several ${request.kind}s.`);
93
+ const at = yield* now;
94
+ const key = yield* fingerprint({
95
+ kind: request.kind,
96
+ client: request.client ?? null,
97
+ actor: request.actor.id,
98
+ input: request.input,
99
+ });
100
+ const since = new Date(Date.parse(at) - REATTACH_WINDOW_MS).toISOString();
101
+ const retried = yield* store.findRetry(key, since);
102
+ if (Option.isSome(retried)) {
103
+ yield* noteWaiting(retried.value, at);
104
+ return yield* view(retried.value);
105
+ }
106
+ if (request.kind === "review") {
107
+ yield* checkImages(request.input.content);
108
+ if (request.input.revises !== undefined) {
109
+ const revised = yield* store.find(request.input.revises);
110
+ if (Option.isNone(revised) || revised.value.kind !== "review") {
111
+ return yield* new ContentInvalid({
112
+ reason: `"revises" names no review: "${request.input.revises}".`,
113
+ });
114
+ }
115
+ }
116
+ }
117
+ const head = {
118
+ id: crypto.randomUUID(),
119
+ key,
120
+ status: "awaiting_human",
121
+ project: request.input.project ?? null,
122
+ client: request.client ?? null,
123
+ raisedBy: request.actor,
124
+ createdAt: at,
125
+ updatedAt: at,
126
+ closedAt: null,
127
+ lastWaitedAt: at,
128
+ };
129
+ let record;
130
+ switch (request.kind) {
131
+ case "question":
132
+ record = { ...head, kind: "question", prompt: questionPrompt(request.input) };
133
+ break;
134
+ case "review":
135
+ record = {
136
+ ...head,
137
+ kind: "review",
138
+ prompt: reviewPrompt(request.input),
139
+ revises: request.input.revises ?? null,
140
+ };
141
+ break;
142
+ case "briefing": {
143
+ const prompt = yield* briefingPrompt(request.input);
144
+ yield* checkImages(blocksOf(request.input.sections.flatMap((section) => section.points)));
145
+ record = { ...head, kind: "briefing", prompt };
146
+ break;
147
+ }
148
+ }
149
+ yield* store.insert(record);
150
+ const ask = yield* view(record);
151
+ yield* announce("ask.opened", ask);
152
+ return ask;
153
+ });
154
+ /**
155
+ * Records the agent's answer to a question the person asked back, and hands the move back to them.
156
+ * Sending the same answer again while it is already their move is a retry, and changes nothing.
157
+ */
158
+ export const answerFollowUp = Effect.fn("Space.answerFollowUp")(function* (input) {
159
+ const store = yield* AskStore;
160
+ const presence = yield* Presence;
161
+ const record = yield* findAsk(input.askId);
162
+ if (record.kind === "briefing") {
163
+ return yield* new ContentInvalid({
164
+ reason: "This is a briefing: answer it with answer_briefing, on each point.",
165
+ });
166
+ }
167
+ if (record.status === "closed")
168
+ return yield* new AskClosed({ askId: record.id });
169
+ if (record.status === "awaiting_human") {
170
+ const thread = yield* store.thread(record.id);
171
+ const last = thread.at(-1)?.entry;
172
+ const same = last?.author === "agent" &&
173
+ last.answer === input.answer &&
174
+ JSON.stringify(last.content ?? []) === JSON.stringify(input.content ?? []);
175
+ if (same)
176
+ return toAsk(record, thread, presence.isWaiting(record.id));
177
+ return yield* new NotYourTurn({ askId: record.id, turn: "human" });
178
+ }
179
+ yield* checkSize({ answer: input.answer, content: input.content });
180
+ yield* checkImages(input.content ?? []);
181
+ const at = yield* now;
182
+ yield* store.transaction(Effect.gen(function* () {
183
+ // Answering the person's question shows the agent had it.
184
+ yield* store.markAllDelivered(record.id, at);
185
+ yield* store.append(record.id, {
186
+ author: "agent",
187
+ answer: input.answer,
188
+ ...(input.content === undefined ? {} : { content: input.content }),
189
+ at,
190
+ });
191
+ yield* store.setStatus(record.id, "awaiting_human", at);
192
+ yield* store.touchWaited(record.id, at);
193
+ }));
194
+ const ask = yield* view({ ...record, status: "awaiting_human", updatedAt: at });
195
+ yield* announce("ask.updated", ask);
196
+ return ask;
197
+ });
198
+ /** Whether a recorded agent entry is this answer, as a retried move would send it again. */
199
+ const sameAnswer = (entry, answer) => entry.point === answer.point &&
200
+ entry.answer === answer.answer &&
201
+ JSON.stringify(entry.content ?? []) === JSON.stringify(answer.content ?? []) &&
202
+ JSON.stringify((entry.points ?? []).map(({ id: _, ...point }) => point)) ===
203
+ JSON.stringify(answer.points ?? []);
204
+ /**
205
+ * The turn a retried move rejoins, if it is one: the agent's latest move is this same move, and
206
+ * nothing the person sent since has reached the agent. Once any reply after the move has reached it,
207
+ * the agent has seen past the move, so the same words again are a new move, never a retry.
208
+ */
209
+ const rejoins = (thread, answers) => {
210
+ const last = thread.findLastIndex((row) => row.entry.author === "agent");
211
+ if (last + 1 < answers.length)
212
+ return undefined;
213
+ const move = thread.slice(last + 1 - answers.length, last + 1);
214
+ const same = move.every((row, n) => {
215
+ const answer = answers[n];
216
+ return row.entry.author === "agent" && answer !== undefined && sameAnswer(row.entry, answer);
217
+ });
218
+ if (!same)
219
+ return undefined;
220
+ const since = thread.slice(last + 1);
221
+ return since.every((row) => row.deliveredAt === null) ? thread[last]?.seq : undefined;
222
+ };
223
+ /**
224
+ * Records the agent's answers on a briefing, one thread entry per point, numbering any sub-points,
225
+ * and hands the move back to the person. A move sent again before its reply reached the agent is a
226
+ * retry: it rejoins the move it repeats, and changes nothing.
227
+ */
228
+ export const answerBriefing = Effect.fn("Space.answerBriefing")(function* (input) {
229
+ const store = yield* AskStore;
230
+ const at = yield* now;
231
+ const { record, turn, made } = yield* store.transaction(Effect.gen(function* () {
232
+ const found = yield* findAsk(input.askId);
233
+ if (found.kind !== "briefing") {
234
+ return yield* new ContentInvalid({
235
+ reason: `This is a ${found.kind}, not a briefing: answer_briefing answers only briefings.`,
236
+ });
237
+ }
238
+ const thread = yield* store.thread(found.id);
239
+ const rejoined = rejoins(thread, input.answers);
240
+ if (rejoined !== undefined)
241
+ return { record: found, turn: rejoined, made: false };
242
+ if (found.status === "closed")
243
+ return yield* new AskClosed({ askId: found.id });
244
+ if (found.status === "awaiting_human") {
245
+ return yield* new NotYourTurn({ askId: found.id, turn: "human" });
246
+ }
247
+ yield* checkSize(input.answers);
248
+ yield* checkImages(input.answers.flatMap((answer) => [
249
+ ...(answer.content ?? []),
250
+ ...blocksOf(answer.points ?? []),
251
+ ]));
252
+ const tree = foldBriefing(found.prompt, thread.map((row) => row.entry));
253
+ const problem = answersProblem(tree, input.answers);
254
+ if (problem !== null)
255
+ return yield* new ContentInvalid({ reason: problem });
256
+ // Answering the person's replies shows the agent had them.
257
+ yield* store.markAllDelivered(found.id, at);
258
+ let last = 0;
259
+ for (const entry of answerEntries(tree, input.answers, at)) {
260
+ last = (yield* store.append(found.id, entry)).seq;
261
+ }
262
+ yield* store.setStatus(found.id, "awaiting_human", at);
263
+ yield* store.touchWaited(found.id, at);
264
+ const updated = { ...found, status: "awaiting_human", updatedAt: at };
265
+ return { record: updated, turn: last, made: true };
266
+ }));
267
+ const ask = yield* view(record);
268
+ if (made)
269
+ yield* announce("ask.updated", ask);
270
+ const move = { ask, turn };
271
+ return move;
272
+ });
273
+ /**
274
+ * Waits up to `waitMs` for a reply the agent has not yet taken, and shows it. `null` means none
275
+ * came: the caller waits again. The reply stays the agent's to take until `markDelivered` says it
276
+ * reached them, so a wait whose caller has gone loses nothing. While a wait is held the ask shows
277
+ * as waiting.
278
+ */
279
+ export const awaitReply = Effect.fn("Space.awaitReply")(function* (askId, waitMs) {
280
+ const store = yield* AskStore;
281
+ const replies = yield* Replies;
282
+ const record = yield* findAsk(askId);
283
+ const take = Effect.gen(function* () {
284
+ const entry = yield* store.undelivered(askId);
285
+ if (Option.isNone(entry) || entry.value.entry.author !== "human")
286
+ return null;
287
+ const delivery = { askId, reply: entry.value.entry.reply, seq: entry.value.seq };
288
+ return delivery;
289
+ });
290
+ const wait = Effect.gen(function* () {
291
+ // Subscribed before the first look, so a reply landing in between still wakes this wait.
292
+ const woken = yield* PubSub.subscribe(replies);
293
+ const ready = yield* take;
294
+ if (ready !== null || waitMs <= 0)
295
+ return ready;
296
+ const later = Effect.gen(function* () {
297
+ for (;;) {
298
+ yield* PubSub.take(woken);
299
+ const delivery = yield* take;
300
+ if (delivery !== null)
301
+ return delivery;
302
+ }
303
+ });
304
+ const held = Duration.millis(Math.min(waitMs, MAX_WAIT_MS));
305
+ return Option.getOrNull(yield* Effect.timeoutOption(later, held));
306
+ });
307
+ return yield* Effect.acquireUseRelease(Effect.gen(function* () {
308
+ yield* follow(askId);
309
+ yield* noteWaiting(record, yield* now);
310
+ }), () => Effect.scoped(wait), () => unfollow(askId));
311
+ });
312
+ /** Records that the agent was handed the reply at `seq`: a retry no longer collects it. */
313
+ export const markDelivered = Effect.fn("Space.markDelivered")(function* (askId, seq) {
314
+ const store = yield* AskStore;
315
+ yield* store.markDelivered(askId, seq, yield* now);
316
+ return null;
317
+ });
318
+ /** The task for turn `afterSeq` of an ask: when it started, and the reply that ends it, if given. */
319
+ const locateTask = Effect.fnUntraced(function* (askId, afterSeq) {
320
+ const store = yield* AskStore;
321
+ const record = yield* findAsk(askId);
322
+ const thread = yield* store.thread(askId);
323
+ let startedAt = afterSeq === 0 ? record.createdAt : undefined;
324
+ let answer;
325
+ for (const row of thread) {
326
+ if (row.seq === afterSeq && row.entry.author === "agent")
327
+ startedAt = row.entry.at;
328
+ if (answer === undefined && row.seq > afterSeq && row.entry.author === "human") {
329
+ answer = {
330
+ seq: row.seq,
331
+ at: row.entry.at,
332
+ reply: { askId, reply: row.entry.reply, seq: row.seq },
333
+ delivered: row.deliveredAt !== null,
334
+ };
335
+ }
336
+ }
337
+ // A turn this ask never had names no task.
338
+ if (startedAt === undefined)
339
+ return yield* new AskNotFound({ askId });
340
+ return { record, startedAt, answer };
341
+ });
342
+ /**
343
+ * One poll of an agent's background task. While it is working, each poll keeps the ask showing an
344
+ * agent following it; once the person replies, it completes with that reply, every time it is asked.
345
+ */
346
+ export const followTask = Effect.fn("Space.followTask")(function* (askId, afterSeq) {
347
+ const store = yield* AskStore;
348
+ const { record, startedAt, answer } = yield* locateTask(askId, afterSeq);
349
+ if (answer !== undefined) {
350
+ if (!answer.delivered)
351
+ yield* store.markDelivered(askId, answer.seq, yield* now);
352
+ const state = {
353
+ status: "completed",
354
+ createdAt: startedAt,
355
+ lastUpdatedAt: answer.at,
356
+ reply: answer.reply,
357
+ };
358
+ return state;
359
+ }
360
+ const cancelledAt = yield* store.taskCancelledAt(askId, afterSeq);
361
+ if (Option.isSome(cancelledAt)) {
362
+ const state = {
363
+ status: "cancelled",
364
+ createdAt: startedAt,
365
+ lastUpdatedAt: cancelledAt.value,
366
+ reply: null,
367
+ };
368
+ return state;
369
+ }
370
+ yield* follow(askId);
371
+ yield* unfollow(askId);
372
+ yield* noteWaiting(record, yield* now);
373
+ const state = {
374
+ status: "working",
375
+ createdAt: startedAt,
376
+ lastUpdatedAt: startedAt,
377
+ reply: null,
378
+ };
379
+ return state;
380
+ });
381
+ /**
382
+ * The agent stops following a background task. The ask stays open, and the person can still
383
+ * answer it; the inbox stops showing anyone waiting. A task that already completed stays completed.
384
+ */
385
+ export const cancelTask = Effect.fn("Space.cancelTask")(function* (askId, afterSeq) {
386
+ const store = yield* AskStore;
387
+ const presence = yield* Presence;
388
+ const { startedAt, answer } = yield* locateTask(askId, afterSeq);
389
+ if (answer !== undefined) {
390
+ const state = {
391
+ status: "completed",
392
+ createdAt: startedAt,
393
+ lastUpdatedAt: answer.at,
394
+ reply: answer.reply,
395
+ };
396
+ return state;
397
+ }
398
+ const at = yield* now;
399
+ yield* store.cancelTask(askId, afterSeq, at);
400
+ if (yield* presence.drop(askId))
401
+ yield* announceFollowing(askId);
402
+ const state = {
403
+ status: "cancelled",
404
+ createdAt: startedAt,
405
+ lastUpdatedAt: at,
406
+ reply: null,
407
+ };
408
+ return state;
409
+ });
410
+ /** Records a person's reply, wakes the agent waiting for it, and tells every inbox. */
411
+ export const reply = Effect.fn("Space.reply")(function* (askId, answer, by) {
412
+ const store = yield* AskStore;
413
+ const replies = yield* Replies;
414
+ const at = yield* now;
415
+ const { record, status } = yield* store.transaction(Effect.gen(function* () {
416
+ const found = yield* findAsk(askId);
417
+ const thread = (yield* store.thread(askId)).map((row) => row.entry);
418
+ yield* checkReply(found, thread, answer);
419
+ const next = statusAfter(found, thread, answer);
420
+ yield* store.append(askId, { author: "human", reply: answer, by, at });
421
+ yield* store.setStatus(askId, next, at);
422
+ return { record: found, status: next };
423
+ }));
424
+ yield* PubSub.publish(replies, undefined);
425
+ const ask = yield* view({
426
+ ...record,
427
+ status,
428
+ updatedAt: at,
429
+ closedAt: status === "closed" ? at : null,
430
+ });
431
+ yield* announce("ask.updated", ask);
432
+ return ask;
433
+ });
434
+ export const get = (askId) => Effect.flatMap(findAsk(askId), view);
435
+ /** Every open ask and the most recently closed. */
436
+ export const snapshot = Effect.gen(function* () {
437
+ const store = yield* AskStore;
438
+ const current = {
439
+ open: yield* views(yield* store.open),
440
+ recent: yield* views((yield* store.closed(undefined, RECENT_LIMIT)).records),
441
+ };
442
+ return current;
443
+ });
444
+ /** A page of closed asks, newest first. */
445
+ export const history = Effect.fn("Space.history")(function* (before, limit) {
446
+ const store = yield* AskStore;
447
+ const { records, nextBefore } = yield* store.closed(before, limit);
448
+ const items = yield* views(records);
449
+ const page = { items, ...(nextBefore === null ? {} : { nextBefore }) };
450
+ return page;
451
+ });
452
+ /** Issues a single-use ticket to upload one image. */
453
+ export const createUpload = Effect.fn("Space.createUpload")(function* (contentType) {
454
+ const uploads = yield* UploadStore;
455
+ const host = yield* Host;
456
+ const at = yield* now;
457
+ const token = secret();
458
+ const issued = {
459
+ uploadId: crypto.randomUUID(),
460
+ spaceId: host.spaceId,
461
+ token,
462
+ contentType,
463
+ expiresAt: new Date(Date.parse(at) + UPLOAD_TTL_MS).toISOString(),
464
+ maxBytes: UPLOAD_MAX_BYTES,
465
+ };
466
+ yield* uploads.insert({
467
+ id: issued.uploadId,
468
+ tokenHash: yield* sha256(token),
469
+ contentType,
470
+ maxBytes: issued.maxBytes,
471
+ status: "pending",
472
+ size: null,
473
+ createdAt: at,
474
+ expiresAt: issued.expiresAt,
475
+ });
476
+ return issued;
477
+ });
478
+ /** Uses up an upload ticket. Once claimed it is spent, whether or not the upload then succeeds. */
479
+ export const claimUpload = Effect.fn("Space.claimUpload")(function* (token, contentType) {
480
+ const uploads = yield* UploadStore;
481
+ const found = yield* uploads.findByToken(yield* sha256(token));
482
+ if (Option.isNone(found))
483
+ return yield* new UploadNotFound();
484
+ const upload = found.value;
485
+ if (upload.status !== "pending" ||
486
+ Date.parse(upload.expiresAt) <= (yield* Clock.currentTimeMillis)) {
487
+ return yield* new UploadExpired();
488
+ }
489
+ const declared = contentType.split(";")[0]?.trim().toLowerCase();
490
+ if (declared !== upload.contentType) {
491
+ return yield* new UploadRejected({
492
+ reason: `This ticket is for ${upload.contentType}, not ${declared || "an unnamed type"}.`,
493
+ });
494
+ }
495
+ yield* uploads.setStatus(upload.id, "claimed", null);
496
+ const claimed = {
497
+ uploadId: upload.id,
498
+ contentType: upload.contentType,
499
+ maxBytes: upload.maxBytes,
500
+ };
501
+ return claimed;
502
+ });
503
+ /** Records that a claimed upload's bytes are stored. */
504
+ export const completeUpload = Effect.fn("Space.completeUpload")(function* (uploadId, size) {
505
+ const uploads = yield* UploadStore;
506
+ const found = yield* uploads.find(uploadId);
507
+ if (Option.isNone(found) || found.value.status !== "claimed")
508
+ return yield* new UploadNotFound();
509
+ yield* uploads.setStatus(uploadId, "stored", size);
510
+ const receipt = { uploadId, contentType: found.value.contentType, size };
511
+ return receipt;
512
+ });
513
+ /** A stored upload's type and size. */
514
+ export const findUpload = Effect.fn("Space.findUpload")(function* (uploadId) {
515
+ const uploads = yield* UploadStore;
516
+ const found = yield* uploads.find(uploadId);
517
+ if (Option.isNone(found) || found.value.status !== "stored")
518
+ return yield* new UploadNotFound();
519
+ const receipt = {
520
+ uploadId,
521
+ contentType: found.value.contentType,
522
+ size: found.value.size ?? 0,
523
+ };
524
+ return receipt;
525
+ });
526
+ /** Issues a one-time pass onto the event socket, held only as its hash. */
527
+ export const issueTicket = Effect.fn("Space.issueTicket")(function* (actorId) {
528
+ const tickets = yield* TicketStore;
529
+ const host = yield* Host;
530
+ const at = yield* now;
531
+ yield* tickets.purge(at);
532
+ const issued = {
533
+ spaceId: host.spaceId,
534
+ secret: secret(),
535
+ expiresAt: new Date(Date.parse(at) + TICKET_TTL_MS).toISOString(),
536
+ };
537
+ yield* tickets.insert(yield* sha256(issued.secret), actorId, issued.expiresAt);
538
+ return issued;
539
+ });
540
+ /** Spends an event ticket: who it was issued to, or `TicketInvalid` when it is unknown, used or expired. */
541
+ export const redeemTicket = Effect.fn("Space.redeemTicket")(function* (ticketSecret) {
542
+ const tickets = yield* TicketStore;
543
+ const at = yield* now;
544
+ const taken = yield* tickets.take(yield* sha256(ticketSecret));
545
+ if (Option.isNone(taken) || taken.value.expiresAt <= at)
546
+ return yield* new TicketInvalid();
547
+ return { actorId: taken.value.actorId };
548
+ });
549
+ /** Everything a space does, by name: what its object serves over RPC. */
550
+ export const operations = {
551
+ open,
552
+ answerFollowUp,
553
+ answerBriefing,
554
+ awaitReply,
555
+ markDelivered,
556
+ followTask,
557
+ cancelTask,
558
+ reply,
559
+ get,
560
+ snapshot: () => snapshot,
561
+ history,
562
+ createUpload,
563
+ claimUpload,
564
+ completeUpload,
565
+ findUpload,
566
+ issueTicket,
567
+ redeemTicket,
568
+ };
@@ -0,0 +1,8 @@
1
+ import { Result, Schema } from "effect";
2
+ /**
3
+ * Reading what a space is handed or holds: decoded with the schemas generated from the contract,
4
+ * never cast. A message holding something from a newer revision of the contract, a member of a union
5
+ * those schemas decode as `unknown`, can't be read either: this code would only guess at it. See
6
+ * docs/design/versioned-messages.md.
7
+ */
8
+ export declare const read: <A>(schema: Schema.Codec<A, unknown>, value: unknown) => Result.Result<A, string>;
@@ -0,0 +1,33 @@
1
+ import { Result, Schema } from "effect";
2
+ /**
3
+ * Reading what a space is handed or holds: decoded with the schemas generated from the contract,
4
+ * never cast. A message holding something from a newer revision of the contract, a member of a union
5
+ * those schemas decode as `unknown`, can't be read either: this code would only guess at it. See
6
+ * docs/design/versioned-messages.md.
7
+ */
8
+ export const read = (schema, value) => {
9
+ const decoded = Schema.decodeUnknownResult(schema)(value);
10
+ if (Result.isFailure(decoded))
11
+ return Result.fail(decoded.failure.message);
12
+ const found = unreadable(decoded.success);
13
+ return found === undefined
14
+ ? Result.succeed(decoded.success)
15
+ : Result.fail(`it holds ${found}, from a newer revision of the contract`);
16
+ };
17
+ /** What a decoded message holds that this code can't read, if anything. */
18
+ const unreadable = (value) => {
19
+ if (typeof value !== "object" || value === null)
20
+ return undefined;
21
+ const record = value;
22
+ for (const tag of ["kind", "type", "outcome"]) {
23
+ if (record[tag] === "unknown" && typeof record["was"] === "string") {
24
+ return `the ${tag} "${record["was"]}"`;
25
+ }
26
+ }
27
+ for (const inner of Object.values(record)) {
28
+ const found = unreadable(inner);
29
+ if (found !== undefined)
30
+ return found;
31
+ }
32
+ return undefined;
33
+ };
@@ -0,0 +1,61 @@
1
+ import { Effect, type Exit, Schema } from "effect";
2
+ import { AskClosed, AskNotFound, ContentInvalid, type Declined, NotYourTurn, ReplyInvalid, TicketInvalid, UploadExpired, UploadNotFound, UploadRejected } from "../domain/errors.ts";
3
+ import { type Operations } from "./operations.ts";
4
+ /**
5
+ * The version of the code making a call: the contract revision it writes, and the deploy it is part
6
+ * of. A space compares them with its own, since for a while after a deploy the Worker and a space can
7
+ * run different code. `build` is the version's creation time, from Cloudflare's version metadata, and
8
+ * absent where there is none (local tests): then nothing is compared.
9
+ */
10
+ export interface Caller {
11
+ readonly revision: number;
12
+ readonly build?: string | undefined;
13
+ }
14
+ /** A deployment's build, from its version metadata binding, if it has one. */
15
+ export declare const buildOf: (env: unknown) => string | undefined;
16
+ /** Whether a build is from a later deploy than another; unknown when either is. */
17
+ export declare const isLater: (build: string | undefined, than: string | undefined) => boolean;
18
+ declare const Outdated_base: Schema.Class<Outdated, Schema.TaggedStruct<"Outdated", {}>, import("effect/Cause").YieldableError>;
19
+ /**
20
+ * A space's refusal of a call from newer code than its own: it ran nothing, and restarts onto the new
21
+ * code a moment later. The client tries the call again, whatever it is, and never hands this on.
22
+ */
23
+ export declare class Outdated extends Outdated_base {
24
+ }
25
+ /** Every error a space operation fails with. */
26
+ export declare const SpaceFailure: Schema.Union<readonly [typeof AskNotFound, typeof AskClosed, typeof NotYourTurn, typeof ReplyInvalid, typeof ContentInvalid, typeof TicketInvalid, typeof UploadNotFound, typeof UploadExpired, typeof UploadRejected]>;
27
+ export type SpaceFailure = typeof SpaceFailure.Type;
28
+ /**
29
+ * An operation's outcome as it crosses the RPC boundary. Errors do not survive structured clone
30
+ * with their tags, so every answer is its Exit encoded as JSON; a defect crosses as its name and
31
+ * message. Success values are the contract's own JSON shapes and pass through unchanged.
32
+ */
33
+ export type ExitJson = Schema.Json;
34
+ /** What a call can fail with across the boundary: an operation's errors, or the space's refusal. */
35
+ declare const CallFailure: Schema.Union<readonly [typeof AskNotFound, typeof AskClosed, typeof NotYourTurn, typeof ReplyInvalid, typeof ContentInvalid, typeof TicketInvalid, typeof UploadNotFound, typeof UploadExpired, typeof UploadRejected, typeof Outdated]>;
36
+ type CallFailure = typeof CallFailure.Type;
37
+ export declare const encodeExit: (exit: Exit.Exit<unknown, CallFailure>) => ExitJson;
38
+ /** A space operation by name, as the object serves it. */
39
+ export type OperationName = keyof Operations;
40
+ /** What the Worker holds to reach a space: its object's single RPC entry point. */
41
+ export interface SpaceStub {
42
+ call(method: OperationName, args: readonly unknown[], caller: Caller): Promise<ExitJson>;
43
+ }
44
+ /** The operations a deployment may decline, wrapping the client: the ones that open something new. */
45
+ export type Declinable = "open" | "createUpload";
46
+ /**
47
+ * A space's operations as seen from the Worker: the same signatures, with no services required. A
48
+ * client a deployment wraps may also decline what it lets open; the space's own never does.
49
+ */
50
+ export type SpaceClient = {
51
+ readonly [K in OperationName]: (...args: Parameters<Operations[K]>) => Effect.Effect<Effect.Success<ReturnType<Operations[K]>>, Effect.Error<ReturnType<Operations[K]>> | (K extends Declinable ? Declined : never)>;
52
+ };
53
+ /**
54
+ * The typed client for one space, for code of the version `caller` names. Each call is decoded back
55
+ * into the operation's own success value or tagged error. A call the object refused is tried again on
56
+ * a fresh stub when it is safe to, as Cloudflare advises; otherwise it is a defect. A call a space
57
+ * refused as `Outdated` ran nothing, so it is tried again whatever it is, until the space is on the
58
+ * new code.
59
+ */
60
+ export declare const spaceClient: (stub: () => SpaceStub, caller: Caller) => SpaceClient;
61
+ export {};