@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.
- package/README.md +46 -8
- package/dist/agents.js +4 -0
- package/dist/auth.d.ts +24 -1
- package/dist/auth.js +38 -0
- package/dist/boot-host-script.js +1 -1
- package/dist/build/candidate-cli.mjs +8 -1
- package/dist/channels/binding.d.ts +8 -1
- package/dist/channels/binding.js +12 -0
- package/dist/channels/conversations.d.ts +16 -5
- package/dist/channels/visitor-channel.d.ts +53 -0
- package/dist/channels/visitor-channel.js +124 -0
- package/dist/channels/visitor-edge.d.ts +115 -0
- package/dist/channels/visitor-edge.js +582 -0
- package/dist/channels/visitor-request.d.ts +7 -0
- package/dist/channels/visitor-request.js +17 -0
- package/dist/channels/visitor-stream-slots.d.ts +2 -0
- package/dist/channels/visitor-stream-slots.js +33 -0
- package/dist/channels/visitors.d.ts +47 -0
- package/dist/channels/visitors.js +181 -0
- package/dist/cloudflare.d.ts +8 -2
- package/dist/cloudflare.js +27 -1
- package/dist/computer-target.d.ts +5 -0
- package/dist/computer-target.js +11 -0
- package/dist/host.d.ts +6 -3
- package/dist/host.js +75 -13
- package/dist/internal/host-gate.d.ts +6 -4
- package/dist/internal/host-gate.js +14 -7
- package/dist/invocation.d.ts +11 -4
- package/dist/invocation.js +134 -91
- package/dist/page/capabilities.js +1 -1
- package/dist/page/child-selection.d.ts +27 -0
- package/dist/page/child-selection.js +38 -0
- package/dist/page/mount.js +1 -1
- package/dist/page-script.js +4 -4
- package/dist/release.d.ts +13 -0
- package/dist/release.js +10 -0
- package/dist/request-origin.d.ts +4 -0
- package/dist/request-origin.js +10 -0
- package/dist/standalone/agents.js +11 -11
- package/dist/standalone/channel-session-door.d.ts +2 -2
- package/dist/standalone/channel-session-door.js +35 -5
- package/dist/standalone/computer-fleet.js +7 -1
- package/dist/standalone/tenancy.d.ts +3 -0
- package/dist/standalone/tenancy.js +7 -2
- package/dist/turnstile.d.ts +27 -0
- package/dist/turnstile.js +51 -0
- package/dist/workers-git.js +20 -1
- package/dist/workers.js +15 -12
- package/docs/api/auth.md +55 -0
- package/docs/api/build-contract.md +2 -2
- package/docs/api/cloudflare.md +26 -1
- package/docs/api/host.md +22 -13
- package/docs/api/invocation.md +7 -3
- package/docs/api/workers.md +17 -16
- package/docs/site/agent-quickstart.mdx +6 -2
- package/examples/standalone/wrangler.celld.jsonc +66 -0
- package/package.json +40 -16
package/dist/invocation.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
203
|
+
const withPermits = orderedPermits(concurrentReadFrames);
|
|
158
204
|
const now = ports.now ?? Date.now;
|
|
159
205
|
let foregroundWaiting = 0;
|
|
160
|
-
|
|
161
|
-
const
|
|
206
|
+
const live = new Map();
|
|
207
|
+
const finish = (state, reason) => {
|
|
162
208
|
if (state.closing)
|
|
163
209
|
return state.closing;
|
|
164
|
-
|
|
165
|
-
active = undefined;
|
|
210
|
+
live.delete(state.frame.frameId);
|
|
166
211
|
state.controller.abort(reason);
|
|
167
|
-
state.closing = (
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
await
|
|
202
|
-
return await
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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) =>
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
322
|
+
return state.frame;
|
|
277
323
|
},
|
|
278
324
|
revoke: (reason = new AppInstanceError("revoked"), target) => {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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 "
|
|
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
|
+
}
|
package/dist/page/mount.js
CHANGED
|
@@ -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 "
|
|
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";
|