@fungi.computer/oyster 0.5.0 → 0.6.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.
Files changed (57) hide show
  1. package/README.md +46 -8
  2. package/dist/agents.js +4 -0
  3. package/dist/auth.d.ts +24 -1
  4. package/dist/auth.js +38 -0
  5. package/dist/boot-host-script.js +1 -1
  6. package/dist/build/candidate-cli.mjs +8 -1
  7. package/dist/channels/binding.d.ts +8 -1
  8. package/dist/channels/binding.js +12 -0
  9. package/dist/channels/conversations.d.ts +16 -5
  10. package/dist/channels/visitor-channel.d.ts +53 -0
  11. package/dist/channels/visitor-channel.js +124 -0
  12. package/dist/channels/visitor-edge.d.ts +115 -0
  13. package/dist/channels/visitor-edge.js +582 -0
  14. package/dist/channels/visitor-request.d.ts +7 -0
  15. package/dist/channels/visitor-request.js +17 -0
  16. package/dist/channels/visitor-stream-slots.d.ts +2 -0
  17. package/dist/channels/visitor-stream-slots.js +33 -0
  18. package/dist/channels/visitors.d.ts +47 -0
  19. package/dist/channels/visitors.js +181 -0
  20. package/dist/cloudflare.d.ts +8 -2
  21. package/dist/cloudflare.js +27 -1
  22. package/dist/computer-target.d.ts +5 -0
  23. package/dist/computer-target.js +11 -0
  24. package/dist/host.d.ts +6 -3
  25. package/dist/host.js +75 -13
  26. package/dist/internal/host-gate.d.ts +6 -4
  27. package/dist/internal/host-gate.js +14 -7
  28. package/dist/invocation.d.ts +11 -4
  29. package/dist/invocation.js +134 -91
  30. package/dist/page/capabilities.js +1 -1
  31. package/dist/page/child-selection.d.ts +27 -0
  32. package/dist/page/child-selection.js +38 -0
  33. package/dist/page/mount.js +1 -1
  34. package/dist/page-script.js +4 -4
  35. package/dist/release.d.ts +13 -0
  36. package/dist/release.js +10 -0
  37. package/dist/request-origin.d.ts +4 -0
  38. package/dist/request-origin.js +10 -0
  39. package/dist/standalone/agents.js +11 -11
  40. package/dist/standalone/channel-session-door.d.ts +2 -2
  41. package/dist/standalone/channel-session-door.js +35 -5
  42. package/dist/standalone/computer-fleet.js +7 -1
  43. package/dist/standalone/tenancy.d.ts +3 -0
  44. package/dist/standalone/tenancy.js +7 -2
  45. package/dist/turnstile.d.ts +27 -0
  46. package/dist/turnstile.js +51 -0
  47. package/dist/workers-git.js +20 -1
  48. package/dist/workers.js +15 -12
  49. package/docs/api/auth.md +55 -0
  50. package/docs/api/build-contract.md +2 -2
  51. package/docs/api/cloudflare.md +26 -1
  52. package/docs/api/host.md +22 -13
  53. package/docs/api/invocation.md +7 -3
  54. package/docs/api/workers.md +17 -16
  55. package/docs/site/agent-quickstart.mdx +6 -2
  56. package/examples/standalone/wrangler.celld.jsonc +66 -0
  57. package/package.json +40 -16
@@ -1,8 +1,6 @@
1
1
  import { approvedBindingGrants } from "./apps/gate.js";
2
2
  import { appToolPolicySchema } from "./apps/tools.js";
3
- import * as Effect from "effect/Effect";
4
3
  import * as Schema from "effect/Schema";
5
- import * as Semaphore from "effect/Semaphore";
6
4
  import { defineIdFamily } from "@fungi.computer/cairn/id";
7
5
  import { sha256DigestSchema } from "@fungi.computer/puffball/effect";
8
6
  import { appBackendBindingKinds, appBackendBindingsSchema, appMethodCallContextSchema, } from "@fungi.computer/puffball/app";
@@ -116,6 +114,8 @@ export async function admitBackgroundFrame(key, frame, raw) {
116
114
  frame.host.origin = decision.kind;
117
115
  return decision;
118
116
  }
117
+ /** Foreground reads one Instance runs at once; every other frame runs alone. */
118
+ const concurrentReadFrames = 8;
119
119
  const frameIds = defineIdFamily({
120
120
  family: "app-invocation-frame",
121
121
  version: 1,
@@ -152,28 +152,79 @@ function immutableFacts(facts) {
152
152
  Object.freeze(facts.ancestry);
153
153
  return Object.freeze(facts);
154
154
  }
155
- /** Serialize authority, preempt replay only for verified writes, and close each guest before admission. */
155
+ /**
156
+ * First-come permits over one guest: a waiting exclusive frame holds back
157
+ * every frame queued after it, so a stream of reads never starves a write.
158
+ */
159
+ function orderedPermits(total) {
160
+ let available = total;
161
+ const waiting = [];
162
+ const admit = () => {
163
+ for (let next = waiting[0]; next !== undefined && next.permits <= available; next = waiting[0]) {
164
+ waiting.shift();
165
+ available -= next.permits;
166
+ next.enter();
167
+ }
168
+ };
169
+ return async (permits, work) => {
170
+ await new Promise((enter) => {
171
+ waiting.push({ permits, enter });
172
+ admit();
173
+ });
174
+ try {
175
+ return await work();
176
+ }
177
+ catch (cause) {
178
+ throw cause instanceof Error
179
+ ? cause
180
+ : new Error("App invocation failed", { cause });
181
+ }
182
+ finally {
183
+ available += permits;
184
+ admit();
185
+ }
186
+ };
187
+ }
188
+ /** A foreground call to a verified read method; only these share the guest. */
189
+ function concurrentRead(facts) {
190
+ return (facts.subject.kind === "call" &&
191
+ facts.subject.replay === undefined &&
192
+ facts.subject.method?.effect === "read");
193
+ }
194
+ /**
195
+ * Admit frames against one guest. Verified foreground reads share it, up to
196
+ * eight at once; writes, raw backend requests, Gills and replay run alone. Every guest binding packet names its frame, so a finished
197
+ * frame's leftover timers cannot borrow a later frame's authority and a
198
+ * normal finish keeps the guest warm. Closing authority early (revocation,
199
+ * expiry, a write preempting replay) aborts the guest and so every frame
200
+ * sharing it; replay only yields to a verified write.
201
+ */
156
202
  export function createInvocationGate(ports) {
157
- const permit = Semaphore.makeUnsafe(1);
203
+ const withPermits = orderedPermits(concurrentReadFrames);
158
204
  const now = ports.now ?? Date.now;
159
205
  let foregroundWaiting = 0;
160
- let active;
161
- const close = (state, reason) => {
206
+ const live = new Map();
207
+ const finish = (state, reason) => {
162
208
  if (state.closing)
163
209
  return state.closing;
164
- if (active === state)
165
- active = undefined;
210
+ live.delete(state.frame.frameId);
166
211
  state.controller.abort(reason);
167
- state.closing = (async () => {
168
- try {
169
- await ports.abortGuest();
170
- }
171
- finally {
172
- await ports.close(state.frame);
173
- }
174
- })();
212
+ state.closing = ports.close(state.frame);
175
213
  return state.closing;
176
214
  };
215
+ // The guest is shared: aborting it ends every live frame's guest work, and
216
+ // each of those frames closes only after the guest abort has completed.
217
+ const abortAll = (reason) => {
218
+ const states = [...live.values()];
219
+ for (const state of states) {
220
+ live.delete(state.frame.frameId);
221
+ state.controller.abort(reason);
222
+ }
223
+ const guest = ports.abortGuest();
224
+ for (const state of states)
225
+ state.closing ??= guest.finally(() => ports.close(state.frame));
226
+ return Promise.all([guest, ...states.map((state) => state.closing)]).then(() => undefined);
227
+ };
177
228
  const run = async (input, host, work) => {
178
229
  let facts;
179
230
  try {
@@ -196,64 +247,63 @@ export function createInvocationGate(ports) {
196
247
  if (queuedForeground &&
197
248
  facts.subject.kind === "call" &&
198
249
  facts.subject.method?.effect === "write" &&
199
- active?.frame.facts.subject.kind === "call" &&
200
- active.frame.facts.subject.replay !== undefined)
201
- await close(active, new AppInstanceError("revoked"));
202
- return await Effect.runPromise(permit.withPermits(1)(Effect.tryPromise({
203
- try: async () => {
204
- if (facts.subject.kind === "call" &&
205
- facts.subject.replay !== undefined &&
206
- foregroundWaiting > 0)
207
- throw new AppInstanceError("revoked");
208
- if (queuedForeground) {
209
- foregroundWaiting -= 1;
210
- queuedForeground = false;
211
- }
212
- const { deadlineAt } = facts;
213
- if (deadlineAt <= now())
214
- throw new AppInstanceError("expired");
215
- const frameId = frameIds.frame.generate();
216
- const controller = new AbortController();
217
- const frame = Object.freeze({
218
- frameId,
219
- facts,
220
- host,
221
- signal: controller.signal,
250
+ [...live.values()].some(({ frame }) => frame.facts.subject.kind === "call" &&
251
+ frame.facts.subject.replay !== undefined))
252
+ await abortAll(new AppInstanceError("revoked"));
253
+ return await withPermits(concurrentRead(facts) ? 1 : concurrentReadFrames, async () => {
254
+ if (facts.subject.kind === "call" &&
255
+ facts.subject.replay !== undefined &&
256
+ foregroundWaiting > 0)
257
+ throw new AppInstanceError("revoked");
258
+ if (queuedForeground) {
259
+ foregroundWaiting -= 1;
260
+ queuedForeground = false;
261
+ }
262
+ const { deadlineAt } = facts;
263
+ if (deadlineAt <= now())
264
+ throw new AppInstanceError("expired");
265
+ const frameId = frameIds.frame.generate();
266
+ const controller = new AbortController();
267
+ const frame = Object.freeze({
268
+ frameId,
269
+ facts,
270
+ host,
271
+ signal: controller.signal,
272
+ });
273
+ const state = { frame, controller };
274
+ live.set(frameId, state);
275
+ let timer;
276
+ let stop = () => undefined;
277
+ const revoked = new Promise((_resolve, reject) => {
278
+ const onAbort = () => {
279
+ const reason = controller.signal.reason;
280
+ reject(reason instanceof Error
281
+ ? reason
282
+ : new AppInstanceError("revoked"));
283
+ };
284
+ controller.signal.addEventListener("abort", onAbort, {
285
+ once: true,
222
286
  });
223
- const state = { frame, controller };
224
- active = state;
225
- let timer;
226
- let stop = () => undefined;
227
- const revoked = new Promise((_resolve, reject) => {
228
- const onAbort = () => {
229
- const reason = controller.signal.reason;
230
- reject(reason instanceof Error
231
- ? reason
232
- : new AppInstanceError("revoked"));
233
- };
234
- controller.signal.addEventListener("abort", onAbort, {
235
- once: true,
236
- });
237
- stop = () => {
238
- controller.signal.removeEventListener("abort", onAbort);
239
- };
240
- timer = setTimeout(() => {
241
- controller.abort(new AppInstanceError("expired"));
242
- }, Math.max(0, deadlineAt - now()));
243
- });
244
- try {
245
- return await Promise.race([work(frame), revoked]);
246
- }
247
- finally {
248
- clearTimeout(timer);
249
- stop();
250
- await close(state, new AppInstanceError("revoked"));
251
- }
252
- },
253
- catch: (cause) => cause instanceof Error
254
- ? cause
255
- : new Error("App invocation failed", { cause }),
256
- })));
287
+ stop = () => {
288
+ controller.signal.removeEventListener("abort", onAbort);
289
+ };
290
+ timer = setTimeout(() => {
291
+ // An expired frame's guest may still be running. Each frame's own
292
+ // run awaits its close, so a failure reaches that caller.
293
+ void Promise.allSettled([
294
+ abortAll(new AppInstanceError("expired")),
295
+ ]);
296
+ }, Math.max(0, deadlineAt - now()));
297
+ });
298
+ try {
299
+ return await Promise.race([work(frame), revoked]);
300
+ }
301
+ finally {
302
+ clearTimeout(timer);
303
+ stop();
304
+ await finish(state, new AppInstanceError("revoked"));
305
+ }
306
+ });
257
307
  }
258
308
  finally {
259
309
  if (queuedForeground)
@@ -262,27 +312,20 @@ export function createInvocationGate(ports) {
262
312
  };
263
313
  return {
264
314
  run,
265
- exclusive: (work) => Effect.runPromise(permit.withPermits(1)(Effect.tryPromise({
266
- try: work,
267
- catch: (cause) => cause instanceof Error
268
- ? cause
269
- : new Error("Instance supervisor operation failed", { cause }),
270
- }))),
271
- active: () => {
272
- if (active === undefined ||
273
- active.controller.signal.aborted ||
274
- active.frame.facts.deadlineAt <= now())
315
+ exclusive: (work) => withPermits(concurrentReadFrames, work),
316
+ active: (frameId) => {
317
+ const state = live.get(frameId);
318
+ if (state === undefined ||
319
+ state.controller.signal.aborted ||
320
+ state.frame.facts.deadlineAt <= now())
275
321
  throw new AppInstanceError("revoked");
276
- return active.frame;
322
+ return state.frame;
277
323
  },
278
324
  revoke: (reason = new AppInstanceError("revoked"), target) => {
279
- if (active === undefined)
280
- return Promise.resolve();
281
- if (target !== undefined &&
282
- (active.frame.facts.subject.kind !== "call" ||
283
- active.frame.facts.subject.context.workflow?.runId !== target.runId))
284
- return Promise.resolve();
285
- return close(active, reason);
325
+ const matched = [...live.values()].some(({ frame }) => target === undefined ||
326
+ (frame.facts.subject.kind === "call" &&
327
+ frame.facts.subject.context.workflow?.runId === target.runId));
328
+ return matched ? abortAll(reason) : Promise.resolve();
286
329
  },
287
330
  };
288
331
  }
@@ -3,7 +3,7 @@ import { compile, parse, pathToRegexp } from "path-to-regexp";
3
3
  import * as Schema from "effect/Schema";
4
4
  import { AppError } from "@fungi.computer/agar";
5
5
  import { parseJsonValue } from "@fungi.computer/whistle/wire";
6
- import { computerTargetUrl } from "./computer-target.js";
6
+ import { computerTargetUrl } from "../computer-target.js";
7
7
  const queryInput = Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Number, Schema.Boolean]));
8
8
  const capabilityInputSchema = Schema.Union([Schema.Json, Schema.Undefined]);
9
9
  function resolveRoute(path, input) {
@@ -0,0 +1,27 @@
1
+ /** The launch fields a window names; none of them is authority. */
2
+ export type ChildLaunch = Readonly<{
3
+ appId: string;
4
+ releaseId: string;
5
+ surfaceId: string;
6
+ href: string;
7
+ }>;
8
+ /** The identity the App's own configuration route answers with. */
9
+ export type ChildIdentity = Readonly<{
10
+ installationId: string;
11
+ appId: string;
12
+ releaseId: string;
13
+ computerId: string | null;
14
+ }>;
15
+ /**
16
+ * The installation page a launch names, or null when the address is not one.
17
+ * Only an `/apps/<installationId>/` page is ever asked for its configuration.
18
+ */
19
+ export declare function childInstallationAddress(href: string, origin: string): URL | null;
20
+ /**
21
+ * The launch still names what the host serves at its address: the same
22
+ * installation, App and Release, under the admitted Computer. The host's
23
+ * configuration read is the one fresh answer; no directory read precedes it.
24
+ */
25
+ export declare function childMatchesLaunch(launch: ChildLaunch, pathname: string, config: ChildIdentity, computerTarget: string | undefined): boolean;
26
+ /** Concurrent callers share the one read in flight; a settled read is never reused. */
27
+ export declare function sharedRead<A>(read: () => Promise<A>): () => Promise<A>;
@@ -0,0 +1,38 @@
1
+ import { addressKey } from "@fungi.computer/nacre/app-directory";
2
+ const installationPath = /^\/apps\/[^/]+\/$/u;
3
+ /**
4
+ * The installation page a launch names, or null when the address is not one.
5
+ * Only an `/apps/<installationId>/` page is ever asked for its configuration.
6
+ */
7
+ export function childInstallationAddress(href, origin) {
8
+ const address = new URL(href, origin);
9
+ return address.origin === origin && installationPath.test(address.pathname)
10
+ ? address
11
+ : null;
12
+ }
13
+ /**
14
+ * The launch still names what the host serves at its address: the same
15
+ * installation, App and Release, under the admitted Computer. The host's
16
+ * configuration read is the one fresh answer; no directory read precedes it.
17
+ */
18
+ export function childMatchesLaunch(launch, pathname, config, computerTarget) {
19
+ return (pathname === `/apps/${encodeURIComponent(config.installationId)}/` &&
20
+ launch.surfaceId ===
21
+ addressKey({
22
+ kind: "installation",
23
+ installationId: config.installationId,
24
+ }) &&
25
+ config.appId === launch.appId &&
26
+ config.releaseId === launch.releaseId &&
27
+ (computerTarget === undefined || config.computerId === computerTarget));
28
+ }
29
+ /** Concurrent callers share the one read in flight; a settled read is never reused. */
30
+ export function sharedRead(read) {
31
+ let inFlight;
32
+ return () => {
33
+ inFlight ??= read().finally(() => {
34
+ inFlight = undefined;
35
+ });
36
+ return inFlight;
37
+ };
38
+ }
@@ -9,7 +9,7 @@ import { RpcTarget, RpcStub } from "@fungi.computer/capnweb";
9
9
  import { EventSource } from "eventsource";
10
10
  import { connectBrowser, UiBrowserError, } from "@fungi.computer/knapsack/ui/browser";
11
11
  import { createPageLifetime } from "./lifetime.js";
12
- import { computerTargetUrl } from "./computer-target.js";
12
+ import { computerTargetUrl } from "../computer-target.js";
13
13
  import * as Schema from "effect/Schema";
14
14
  import { AppError, createCapabilityRegistry, } from "@fungi.computer/agar";
15
15
  import { appStorage } from "@fungi.computer/agar/storage";