@fungi.computer/knapsack 0.1.0 → 0.2.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.
- package/README.md +42 -5
- package/dist/files-patch.d.ts +16 -2
- package/dist/files-patch.js +8 -1
- package/dist/files-read.d.ts +108 -3
- package/dist/files-read.js +77 -7
- package/dist/files-search.d.ts +14 -0
- package/dist/files-search.js +52 -0
- package/dist/files.d.ts +40 -46
- package/dist/files.js +171 -85
- package/dist/index.d.ts +8 -1
- package/dist/index.js +5 -1
- package/dist/mcp.d.ts +1 -2
- package/dist/ui/browser.d.ts +44 -0
- package/dist/ui/browser.js +171 -0
- package/dist/ui/effect.d.ts +106 -0
- package/dist/ui/effect.js +208 -0
- package/dist/ui/host.d.ts +9 -0
- package/dist/ui/host.js +158 -0
- package/dist/ui/sessions.d.ts +32 -0
- package/dist/ui/sessions.js +172 -0
- package/dist/ui/wire.d.ts +290 -0
- package/dist/ui/wire.js +136 -0
- package/dist/ui-browser.d.ts +6 -0
- package/dist/ui-browser.js +6 -0
- package/dist/ui.d.ts +29 -0
- package/dist/ui.js +35 -0
- package/docs/api/README.md +1 -0
- package/docs/api/files-patch.md +39 -43
- package/docs/api/files-read.md +263 -27
- package/docs/api/index.md +209 -108
- package/docs/api/ui-browser.md +94 -0
- package/package.json +16 -5
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Scope from "effect/Scope";
|
|
3
|
+
import { type BrowserRequest, type BrowserRequestEnvelope, type BrowserClaimResult, type BrowserWireResult, type BrowserSettlementResult, type BrowserWireObject } from "./wire.js";
|
|
4
|
+
declare const decodeResultEnvelope: (input: BrowserWireObject) => Effect.Effect<{
|
|
5
|
+
requestId: string;
|
|
6
|
+
claimId: string;
|
|
7
|
+
result: {
|
|
8
|
+
type: "discover";
|
|
9
|
+
commands: ({
|
|
10
|
+
action: {
|
|
11
|
+
inputSchema: Record<string, import("zod").JSONType>;
|
|
12
|
+
outputSchema: Record<string, import("zod").JSONType>;
|
|
13
|
+
} | {
|
|
14
|
+
inputSchema: Record<string, import("zod").JSONType>;
|
|
15
|
+
} | {
|
|
16
|
+
outputSchema: Record<string, import("zod").JSONType>;
|
|
17
|
+
};
|
|
18
|
+
commandId: string;
|
|
19
|
+
sourceId: string;
|
|
20
|
+
title: string;
|
|
21
|
+
category: string;
|
|
22
|
+
description: string | null;
|
|
23
|
+
order: number;
|
|
24
|
+
keybind: string | null;
|
|
25
|
+
availability: {
|
|
26
|
+
status: "available";
|
|
27
|
+
} | {
|
|
28
|
+
status: "unavailable";
|
|
29
|
+
reason: string;
|
|
30
|
+
};
|
|
31
|
+
} | {
|
|
32
|
+
commandId: string;
|
|
33
|
+
sourceId: string;
|
|
34
|
+
title: string;
|
|
35
|
+
category: string;
|
|
36
|
+
description: string | null;
|
|
37
|
+
order: number;
|
|
38
|
+
keybind: string | null;
|
|
39
|
+
availability: {
|
|
40
|
+
status: "available";
|
|
41
|
+
} | {
|
|
42
|
+
status: "unavailable";
|
|
43
|
+
reason: string;
|
|
44
|
+
};
|
|
45
|
+
})[];
|
|
46
|
+
} | {
|
|
47
|
+
type: "execute";
|
|
48
|
+
outcome: {
|
|
49
|
+
status: "handled";
|
|
50
|
+
result: import("zod").JSONType;
|
|
51
|
+
} | {
|
|
52
|
+
status: "handled";
|
|
53
|
+
} | {
|
|
54
|
+
status: "closed";
|
|
55
|
+
} | {
|
|
56
|
+
status: "missing";
|
|
57
|
+
} | {
|
|
58
|
+
status: "invalid";
|
|
59
|
+
} | {
|
|
60
|
+
status: "unavailable";
|
|
61
|
+
reason: string;
|
|
62
|
+
} | {
|
|
63
|
+
status: "failed";
|
|
64
|
+
error: {
|
|
65
|
+
code: string;
|
|
66
|
+
message: string;
|
|
67
|
+
details: import("zod").JSONType;
|
|
68
|
+
};
|
|
69
|
+
} | {
|
|
70
|
+
status: "failed";
|
|
71
|
+
error: {
|
|
72
|
+
code: string;
|
|
73
|
+
message: string;
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
}, import("effect/Cause").UnknownError, never>;
|
|
78
|
+
declare const decodeClaim: (input: BrowserWireObject) => Effect.Effect<{
|
|
79
|
+
requestId: string;
|
|
80
|
+
connectionId: string;
|
|
81
|
+
attemptId: string;
|
|
82
|
+
}, import("effect/Cause").UnknownError, never>;
|
|
83
|
+
export type UiHostErrorCode = "browser_busy" | "browser_disconnected" | "browser_execution_indeterminate" | "browser_not_connected" | "invalid_browser_wire";
|
|
84
|
+
/** Closed failure vocabulary for the connected-browser request boundary. */
|
|
85
|
+
export declare class UiHostError extends Error {
|
|
86
|
+
readonly code: UiHostErrorCode;
|
|
87
|
+
constructor(code: UiHostErrorCode, message: string);
|
|
88
|
+
}
|
|
89
|
+
/** One scope-owned live browser connection. */
|
|
90
|
+
export type UiHostConnection = Readonly<{
|
|
91
|
+
id: string;
|
|
92
|
+
take: Effect.Effect<BrowserRequestEnvelope>;
|
|
93
|
+
}>;
|
|
94
|
+
export type UiHostOptions = Readonly<{
|
|
95
|
+
claimLeaseMs?: number;
|
|
96
|
+
}>;
|
|
97
|
+
/** Canonical Effect-owned browser host execution and connection lifecycle. */
|
|
98
|
+
export type UiHost = Readonly<{
|
|
99
|
+
connect: () => Effect.Effect<UiHostConnection, never, Scope.Scope>;
|
|
100
|
+
request: (request: BrowserRequest) => Effect.Effect<BrowserWireResult, UiHostError>;
|
|
101
|
+
claim: (input: Parameters<typeof decodeClaim>[0]) => Effect.Effect<BrowserClaimResult, UiHostError>;
|
|
102
|
+
acceptResult: (input: Parameters<typeof decodeResultEnvelope>[0]) => Effect.Effect<BrowserSettlementResult, UiHostError>;
|
|
103
|
+
}>;
|
|
104
|
+
/** Construct the single Effect-owned connected-browser runtime. */
|
|
105
|
+
export declare function make(options?: UiHostOptions): Effect.Effect<UiHost>;
|
|
106
|
+
export {};
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import * as Semaphore from "effect/Semaphore";
|
|
2
|
+
import * as Deferred from "effect/Deferred";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Queue from "effect/Queue";
|
|
5
|
+
import * as Scope from "effect/Scope";
|
|
6
|
+
import { claimThenSettle } from "@fungi.computer/mycelium/semaphore-transition";
|
|
7
|
+
import { uiWireIds, BrowserClaimSchema, BrowserResultEnvelopeSchema, } from "./wire.js";
|
|
8
|
+
const decodeResultEnvelope = (input) => Effect.try(() => BrowserResultEnvelopeSchema.parse(input));
|
|
9
|
+
const decodeClaim = (input) => Effect.try(() => BrowserClaimSchema.parse(input));
|
|
10
|
+
/** Closed failure vocabulary for the connected-browser request boundary. */
|
|
11
|
+
export class UiHostError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
constructor(code, message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.name = "UiHostError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function disconnected(request) {
|
|
20
|
+
return request.expected === "execute" && request.phase === "claimed"
|
|
21
|
+
? new UiHostError("browser_execution_indeterminate", "The browser disconnected after execution dispatch")
|
|
22
|
+
: new UiHostError("browser_disconnected", "The connected browser disconnected before returning a result");
|
|
23
|
+
}
|
|
24
|
+
function activeClaimResult(lease, claimed, now, connected) {
|
|
25
|
+
if (lease === undefined ||
|
|
26
|
+
lease.expiresAt <= now ||
|
|
27
|
+
!connected.has(lease.connectionId))
|
|
28
|
+
return undefined;
|
|
29
|
+
if (lease.connectionId === claimed.connectionId &&
|
|
30
|
+
lease.attemptId === claimed.attemptId)
|
|
31
|
+
return { status: "claimed", claimId: lease.claimId };
|
|
32
|
+
return {
|
|
33
|
+
status: "waiting",
|
|
34
|
+
retryAfterMs: Math.max(10, Math.min(1_000, lease.expiresAt - now)),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function hasExpiredClaim(lease, now) {
|
|
38
|
+
return lease !== undefined && lease.expiresAt <= now;
|
|
39
|
+
}
|
|
40
|
+
function ownsLiveClaim(request, claimId, now) {
|
|
41
|
+
return (request.phase === "claimed" &&
|
|
42
|
+
request.claim?.claimId === claimId &&
|
|
43
|
+
!hasExpiredClaim(request.claim, now));
|
|
44
|
+
}
|
|
45
|
+
/** Construct the single Effect-owned connected-browser runtime. */
|
|
46
|
+
export function make(options = {}) {
|
|
47
|
+
return Effect.gen(function* () {
|
|
48
|
+
const gate = yield* Semaphore.make(1);
|
|
49
|
+
const connections = new Map();
|
|
50
|
+
const requestedClaimLeaseMs = options.claimLeaseMs ?? 30_000;
|
|
51
|
+
const claimLeaseMs = Number.isFinite(requestedClaimLeaseMs)
|
|
52
|
+
? Math.max(100, Math.min(60_000, requestedClaimLeaseMs))
|
|
53
|
+
: 30_000;
|
|
54
|
+
let pending;
|
|
55
|
+
const claimDisconnection = (connection) => {
|
|
56
|
+
connections.delete(connection.id);
|
|
57
|
+
let failed;
|
|
58
|
+
let reoffer;
|
|
59
|
+
if (pending?.phase === "claimed" &&
|
|
60
|
+
pending.claim?.connectionId === connection.id) {
|
|
61
|
+
if (connections.size > 0) {
|
|
62
|
+
pending = { ...pending, phase: "queued", claim: undefined };
|
|
63
|
+
reoffer = {
|
|
64
|
+
requestId: pending.requestId,
|
|
65
|
+
request: pending.request,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
failed = pending;
|
|
70
|
+
pending = undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
else if (pending !== undefined && connections.size === 0) {
|
|
74
|
+
failed = pending;
|
|
75
|
+
pending = undefined;
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
connection,
|
|
79
|
+
failed,
|
|
80
|
+
reoffer,
|
|
81
|
+
recipients: [...connections.values()],
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
const settleDisconnection = (disconnection) => {
|
|
85
|
+
const reoffer = disconnection.reoffer;
|
|
86
|
+
return (disconnection.failed === undefined
|
|
87
|
+
? Effect.void
|
|
88
|
+
: Deferred.fail(disconnection.failed.result, disconnected(disconnection.failed)).pipe(Effect.asVoid)).pipe(Effect.andThen(reoffer === undefined
|
|
89
|
+
? Effect.void
|
|
90
|
+
: Effect.forEach(disconnection.recipients, (recipient) => Queue.offer(recipient.requests, reoffer), { discard: true })), Effect.andThen(Queue.shutdown(disconnection.connection.requests)));
|
|
91
|
+
};
|
|
92
|
+
const disconnect = (connection) => claimThenSettle(gate, () => claimDisconnection(connection), settleDisconnection);
|
|
93
|
+
const connect = () => Effect.acquireRelease(Effect.gen(function* () {
|
|
94
|
+
const requests = yield* Queue.sliding(1);
|
|
95
|
+
const connection = {
|
|
96
|
+
id: uiWireIds.connection.generate(),
|
|
97
|
+
requests,
|
|
98
|
+
};
|
|
99
|
+
yield* gate.withPermits(1)(Effect.sync(() => {
|
|
100
|
+
connections.set(connection.id, connection);
|
|
101
|
+
if (pending !== undefined && !pending.cancelled)
|
|
102
|
+
Queue.offerUnsafe(connection.requests, {
|
|
103
|
+
requestId: pending.requestId,
|
|
104
|
+
request: pending.request,
|
|
105
|
+
});
|
|
106
|
+
}));
|
|
107
|
+
return connection;
|
|
108
|
+
}), disconnect).pipe(Effect.map((connection) => {
|
|
109
|
+
const take = () => Queue.take(connection.requests).pipe(Effect.flatMap((envelope) => gate
|
|
110
|
+
.withPermits(1)(Effect.sync(() => {
|
|
111
|
+
const owned = pending;
|
|
112
|
+
return (owned?.requestId === envelope.requestId &&
|
|
113
|
+
connections.has(connection.id));
|
|
114
|
+
}))
|
|
115
|
+
.pipe(Effect.flatMap((live) => live ? Effect.succeed(envelope) : Effect.suspend(take)))));
|
|
116
|
+
return Object.freeze({
|
|
117
|
+
id: connection.id,
|
|
118
|
+
take: Effect.suspend(take),
|
|
119
|
+
});
|
|
120
|
+
}));
|
|
121
|
+
const markCancelled = (requestId) => claimThenSettle(gate, () => {
|
|
122
|
+
if (pending?.requestId === requestId)
|
|
123
|
+
pending = { ...pending, cancelled: true };
|
|
124
|
+
}, () => Effect.void);
|
|
125
|
+
const request = (browserRequest) => Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
|
|
126
|
+
const result = yield* Deferred.make();
|
|
127
|
+
let requestId;
|
|
128
|
+
const run = claimThenSettle(gate, () => {
|
|
129
|
+
if (connections.size === 0)
|
|
130
|
+
return { status: "offline" };
|
|
131
|
+
if (pending !== undefined)
|
|
132
|
+
return { status: "busy" };
|
|
133
|
+
const id = uiWireIds.request.generate();
|
|
134
|
+
const envelope = {
|
|
135
|
+
requestId: id,
|
|
136
|
+
request: browserRequest,
|
|
137
|
+
};
|
|
138
|
+
pending = {
|
|
139
|
+
requestId: id,
|
|
140
|
+
request: browserRequest,
|
|
141
|
+
cancelled: false,
|
|
142
|
+
expected: browserRequest.type,
|
|
143
|
+
phase: "queued",
|
|
144
|
+
claim: undefined,
|
|
145
|
+
result,
|
|
146
|
+
};
|
|
147
|
+
for (const connection of connections.values())
|
|
148
|
+
Queue.offerUnsafe(connection.requests, envelope);
|
|
149
|
+
requestId = id;
|
|
150
|
+
return { status: "admitted" };
|
|
151
|
+
}, (admission) => {
|
|
152
|
+
if (admission.status === "offline")
|
|
153
|
+
return Effect.fail(new UiHostError("browser_not_connected", "No browser is connected"));
|
|
154
|
+
if (admission.status === "busy")
|
|
155
|
+
return Effect.fail(new UiHostError("browser_busy", "The connected browser already has pending work"));
|
|
156
|
+
return Effect.void;
|
|
157
|
+
}).pipe(Effect.andThen(Deferred.await(result)));
|
|
158
|
+
return yield* restore(run).pipe(Effect.onInterrupt(() => requestId === undefined ? Effect.void : markCancelled(requestId)));
|
|
159
|
+
}));
|
|
160
|
+
const claim = (input) => decodeClaim(input).pipe(Effect.mapError(() => new UiHostError("invalid_browser_wire", "Browser claim is invalid")), Effect.flatMap((claimed) => claimThenSettle(gate, () => {
|
|
161
|
+
const owned = pending;
|
|
162
|
+
if (owned?.requestId !== claimed.requestId)
|
|
163
|
+
return { status: "skipped" };
|
|
164
|
+
if (owned.cancelled) {
|
|
165
|
+
pending = undefined;
|
|
166
|
+
return { status: "skipped" };
|
|
167
|
+
}
|
|
168
|
+
const now = Date.now();
|
|
169
|
+
const active = activeClaimResult(owned.claim, claimed, now, new Set(connections.keys()));
|
|
170
|
+
if (active !== undefined)
|
|
171
|
+
return active;
|
|
172
|
+
if (!connections.has(claimed.connectionId))
|
|
173
|
+
return { status: "skipped" };
|
|
174
|
+
const lease = {
|
|
175
|
+
attemptId: claimed.attemptId,
|
|
176
|
+
claimId: uiWireIds.claim.generate(),
|
|
177
|
+
connectionId: claimed.connectionId,
|
|
178
|
+
expiresAt: now + claimLeaseMs,
|
|
179
|
+
};
|
|
180
|
+
pending = { ...owned, phase: "claimed", claim: lease };
|
|
181
|
+
return { status: "claimed", claimId: lease.claimId };
|
|
182
|
+
}, Effect.succeed)));
|
|
183
|
+
const acceptResult = (input) => decodeResultEnvelope(input).pipe(Effect.mapError(() => new UiHostError("invalid_browser_wire", "Browser result is invalid")), Effect.flatMap((envelope) => claimThenSettle(gate, () => {
|
|
184
|
+
const owned = pending;
|
|
185
|
+
if (owned?.requestId !== envelope.requestId)
|
|
186
|
+
return { status: "ignored" };
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
if (!ownsLiveClaim(owned, envelope.claimId, now)) {
|
|
189
|
+
if (hasExpiredClaim(owned.claim, now))
|
|
190
|
+
pending = { ...owned, phase: "queued", claim: undefined };
|
|
191
|
+
return { status: "ignored" };
|
|
192
|
+
}
|
|
193
|
+
pending = undefined;
|
|
194
|
+
if (owned.expected !== envelope.result.type) {
|
|
195
|
+
const failure = new UiHostError("invalid_browser_wire", "Browser result does not match its request");
|
|
196
|
+
return { status: "mismatch", owned, failure };
|
|
197
|
+
}
|
|
198
|
+
return { status: "accepted", owned };
|
|
199
|
+
}, (settlement) => {
|
|
200
|
+
if (settlement.status === "ignored")
|
|
201
|
+
return Effect.succeed({ status: "ignored" });
|
|
202
|
+
if (settlement.status === "mismatch")
|
|
203
|
+
return Deferred.fail(settlement.owned.result, settlement.failure).pipe(Effect.andThen(Effect.fail(settlement.failure)));
|
|
204
|
+
return Deferred.succeed(settlement.owned.result, envelope.result).pipe(Effect.as({ status: "accepted" }));
|
|
205
|
+
})));
|
|
206
|
+
return Object.freeze({ connect, request, claim, acceptResult });
|
|
207
|
+
});
|
|
208
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type ModuleRegistration } from "@fungi.computer/mycelium";
|
|
2
|
+
import { type UiHost as EffectUiHost } from "./effect.js";
|
|
3
|
+
/** Promise/HTTP facade over one Session's Effect-owned browser host. */
|
|
4
|
+
export type UiHostFacade = Readonly<{
|
|
5
|
+
modules: readonly [ModuleRegistration];
|
|
6
|
+
fetch: (request: Request) => Promise<Response>;
|
|
7
|
+
}>;
|
|
8
|
+
/** Build one Session browser host: its Mycelium `ui` module and HTTP transport. */
|
|
9
|
+
export declare function createUiHost(runtime?: EffectUiHost): UiHostFacade;
|
package/dist/ui/host.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { Mycelium } from "@fungi.computer/mycelium";
|
|
2
|
+
import * as Cause from "effect/Cause";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Exit from "effect/Exit";
|
|
5
|
+
import * as Option from "effect/Option";
|
|
6
|
+
import * as Stream from "effect/Stream";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { UiHostError, make as makeEffect, } from "./effect.js";
|
|
9
|
+
import { BrowserCommandRowSchema, BrowserExecutionOutcomeSchema, BrowserExecuteInputSchema, BrowserWireObjectSchema, } from "./wire.js";
|
|
10
|
+
const browserUnavailableSchema = z.object({
|
|
11
|
+
status: z.literal("unavailable"),
|
|
12
|
+
reason: z.enum([
|
|
13
|
+
"browser_busy",
|
|
14
|
+
"browser_not_connected",
|
|
15
|
+
"browser_disconnected",
|
|
16
|
+
]),
|
|
17
|
+
});
|
|
18
|
+
const browserExecutionIndeterminateSchema = z.object({
|
|
19
|
+
status: z.literal("indeterminate"),
|
|
20
|
+
reason: z.literal("browser_disconnected_after_dispatch"),
|
|
21
|
+
});
|
|
22
|
+
const browserCommandDiscoverySchema = z.discriminatedUnion("status", [
|
|
23
|
+
z.object({
|
|
24
|
+
status: z.literal("available"),
|
|
25
|
+
commands: z.array(BrowserCommandRowSchema),
|
|
26
|
+
}),
|
|
27
|
+
browserUnavailableSchema,
|
|
28
|
+
]);
|
|
29
|
+
const browserCommandExecutionSchema = z.union([
|
|
30
|
+
BrowserExecutionOutcomeSchema,
|
|
31
|
+
browserUnavailableSchema,
|
|
32
|
+
browserExecutionIndeterminateSchema,
|
|
33
|
+
]);
|
|
34
|
+
async function runPromiseAtBoundary(effect, signal) {
|
|
35
|
+
signal?.throwIfAborted();
|
|
36
|
+
const exit = await Effect.runPromiseExit(effect, signal === undefined ? undefined : { signal });
|
|
37
|
+
if (Exit.isSuccess(exit))
|
|
38
|
+
return exit.value;
|
|
39
|
+
const failure = Cause.findErrorOption(exit.cause);
|
|
40
|
+
if (Option.isSome(failure))
|
|
41
|
+
throw failure.value;
|
|
42
|
+
if (Cause.hasInterruptsOnly(exit.cause)) {
|
|
43
|
+
signal?.throwIfAborted();
|
|
44
|
+
throw new DOMException("The operation was aborted", "AbortError");
|
|
45
|
+
}
|
|
46
|
+
const squashed = Cause.squash(exit.cause);
|
|
47
|
+
if (squashed instanceof Error)
|
|
48
|
+
throw squashed;
|
|
49
|
+
throw new Error("Live App command host failed without an Error cause");
|
|
50
|
+
}
|
|
51
|
+
function unavailable(reason) {
|
|
52
|
+
return { status: "unavailable", reason };
|
|
53
|
+
}
|
|
54
|
+
function recoverUnavailable(error) {
|
|
55
|
+
switch (error.code) {
|
|
56
|
+
case "browser_busy":
|
|
57
|
+
case "browser_not_connected":
|
|
58
|
+
case "browser_disconnected":
|
|
59
|
+
return Effect.succeed(unavailable(error.code));
|
|
60
|
+
default:
|
|
61
|
+
return Effect.fail(error);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function discoveryRequest(runtime, request) {
|
|
65
|
+
return runtime.request(request).pipe(Effect.catch(recoverUnavailable));
|
|
66
|
+
}
|
|
67
|
+
function executionRequest(runtime, request) {
|
|
68
|
+
return runtime.request(request).pipe(Effect.catch((error) => {
|
|
69
|
+
if (error.code === "browser_execution_indeterminate")
|
|
70
|
+
return Effect.succeed({
|
|
71
|
+
status: "indeterminate",
|
|
72
|
+
reason: "browser_disconnected_after_dispatch",
|
|
73
|
+
});
|
|
74
|
+
return recoverUnavailable(error);
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
function discoveryResult(result) {
|
|
78
|
+
if ("status" in result)
|
|
79
|
+
return result;
|
|
80
|
+
if (result.type !== "discover")
|
|
81
|
+
throw new UiHostError("invalid_browser_wire", "Browser returned an execution result for discovery");
|
|
82
|
+
return { status: "available", commands: [...result.commands] };
|
|
83
|
+
}
|
|
84
|
+
function executionResult(result) {
|
|
85
|
+
if ("status" in result)
|
|
86
|
+
return result;
|
|
87
|
+
if (result.type !== "execute")
|
|
88
|
+
throw new UiHostError("invalid_browser_wire", "Browser returned a discovery result for execution");
|
|
89
|
+
return result.outcome;
|
|
90
|
+
}
|
|
91
|
+
function eventResponse(runtime) {
|
|
92
|
+
const body = Stream.unwrap(runtime.connect().pipe(Effect.map((connection) => Stream.make(": connected\n\n").pipe(Stream.concat(Stream.fromEffectRepeat(connection.take).pipe(Stream.map((envelope) => `event: browser-request\ndata: ${JSON.stringify({
|
|
93
|
+
...envelope,
|
|
94
|
+
connectionId: connection.id,
|
|
95
|
+
})}\n\n`))))))).pipe(Stream.encodeText, (stream) => Stream.toReadableStream(stream, {
|
|
96
|
+
strategy: { highWaterMark: 1 },
|
|
97
|
+
}));
|
|
98
|
+
return new Response(body, {
|
|
99
|
+
headers: {
|
|
100
|
+
"Cache-Control": "no-cache",
|
|
101
|
+
"Content-Encoding": "Identity",
|
|
102
|
+
"Content-Type": "text/event-stream",
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
function errorResponse(cause) {
|
|
107
|
+
if (!(cause instanceof UiHostError))
|
|
108
|
+
return Response.json({ error: "Live App commands are unavailable" }, { status: 503 });
|
|
109
|
+
const status = cause.code === "invalid_browser_wire" ? 400 : 503;
|
|
110
|
+
return Response.json({ error: cause.message }, { status });
|
|
111
|
+
}
|
|
112
|
+
async function browserIngressResponse(request, invalidMessage, ingress) {
|
|
113
|
+
const parsed = Effect.tryPromise({
|
|
114
|
+
try: () => request.json(),
|
|
115
|
+
catch: () => new UiHostError("invalid_browser_wire", invalidMessage),
|
|
116
|
+
}).pipe(Effect.flatMap((input) => Effect.try(() => BrowserWireObjectSchema.parse(input))), Effect.flatMap(ingress));
|
|
117
|
+
try {
|
|
118
|
+
return Response.json(await runPromiseAtBoundary(parsed, request.signal));
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
return errorResponse(error);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** Build one Session browser host: its Mycelium `ui` module and HTTP transport. */
|
|
125
|
+
export function createUiHost(runtime = Effect.runSync(makeEffect())) {
|
|
126
|
+
const module = Mycelium.module({
|
|
127
|
+
id: "knapsack-ui",
|
|
128
|
+
name: "ui",
|
|
129
|
+
description: "Live semantic commands from the connected browser",
|
|
130
|
+
operations: {
|
|
131
|
+
discover: Mycelium.operation({
|
|
132
|
+
replay: "safe",
|
|
133
|
+
description: "Discover commands in the connected browser",
|
|
134
|
+
input: z.object({}),
|
|
135
|
+
output: browserCommandDiscoverySchema,
|
|
136
|
+
execute: (_input, context) => runPromiseAtBoundary(discoveryRequest(runtime, { type: "discover" }).pipe(Effect.map(discoveryResult)), context.signal),
|
|
137
|
+
}),
|
|
138
|
+
execute: Mycelium.operation({
|
|
139
|
+
replay: "unsafe",
|
|
140
|
+
description: "Execute one discovered command in the connected browser",
|
|
141
|
+
input: BrowserExecuteInputSchema,
|
|
142
|
+
output: browserCommandExecutionSchema,
|
|
143
|
+
execute: (input, context) => runPromiseAtBoundary(executionRequest(runtime, { type: "execute", ...input }).pipe(Effect.map(executionResult)), context.signal),
|
|
144
|
+
}),
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
const fetch = async (request) => {
|
|
148
|
+
const url = new URL(request.url);
|
|
149
|
+
if (request.method === "GET" && url.pathname === "/browser/events")
|
|
150
|
+
return eventResponse(runtime);
|
|
151
|
+
if (request.method === "POST" && url.pathname === "/browser/claims")
|
|
152
|
+
return browserIngressResponse(request, "Browser claim is invalid", runtime.claim);
|
|
153
|
+
if (request.method === "POST" && url.pathname === "/browser/results")
|
|
154
|
+
return browserIngressResponse(request, "Browser result is invalid", runtime.acceptResult);
|
|
155
|
+
return Response.json({ error: "Not found" }, { status: 404 });
|
|
156
|
+
};
|
|
157
|
+
return Object.freeze({ modules: [module], fetch });
|
|
158
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ModuleRegistration } from "@fungi.computer/mycelium";
|
|
2
|
+
/** The maintained browser transport and its Session-bound tool descriptors. */
|
|
3
|
+
export type BrowserHost = Readonly<{
|
|
4
|
+
/** Mycelium tools bound to this host's Session and call-time gates. */
|
|
5
|
+
modules: readonly ModuleRegistration[];
|
|
6
|
+
/** Forward one browser events, claims or results request to the maintained host. */
|
|
7
|
+
fetch: (request: Request) => Promise<Response>;
|
|
8
|
+
}>;
|
|
9
|
+
/** One run's borrow of its Session's browser tools; release ends the borrow. */
|
|
10
|
+
export type BrowserLease = Readonly<{
|
|
11
|
+
modules: BrowserHost["modules"];
|
|
12
|
+
release: () => void;
|
|
13
|
+
}>;
|
|
14
|
+
/** One ephemeral browser host per Session, borrowed by run and SSE scopes. */
|
|
15
|
+
export declare class BrowserSessions {
|
|
16
|
+
private readonly open;
|
|
17
|
+
private readonly hasSession;
|
|
18
|
+
private generation;
|
|
19
|
+
private closed;
|
|
20
|
+
private readonly hosts;
|
|
21
|
+
constructor(open: (sessionId: string) => BrowserHost, hasSession: (sessionId: string) => Promise<boolean>);
|
|
22
|
+
/** End every browser stream and refuse later requests and run leases. */
|
|
23
|
+
close(): Promise<void>;
|
|
24
|
+
fetch(request: Request): Promise<Response>;
|
|
25
|
+
/** Own the async transport opening until its stream joins the connection scope. */
|
|
26
|
+
private openEvents;
|
|
27
|
+
private createHost;
|
|
28
|
+
acquire(sessionId: string): BrowserLease;
|
|
29
|
+
private releaseBrowserConnection;
|
|
30
|
+
private releaseIdleHost;
|
|
31
|
+
private trackBrowserConnection;
|
|
32
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/** One ephemeral browser host per Session, borrowed by run and SSE scopes. */
|
|
2
|
+
export class BrowserSessions {
|
|
3
|
+
open;
|
|
4
|
+
hasSession;
|
|
5
|
+
generation = 0;
|
|
6
|
+
closed = false;
|
|
7
|
+
hosts = new Map();
|
|
8
|
+
constructor(open, hasSession) {
|
|
9
|
+
this.open = open;
|
|
10
|
+
this.hasSession = hasSession;
|
|
11
|
+
}
|
|
12
|
+
/** End every browser stream and refuse later requests and run leases. */
|
|
13
|
+
async close() {
|
|
14
|
+
this.closed = true;
|
|
15
|
+
// A pending opening or Session lookup from before close never escapes.
|
|
16
|
+
this.generation += 1;
|
|
17
|
+
const old = [...this.hosts.values()];
|
|
18
|
+
this.hosts.clear();
|
|
19
|
+
await Promise.all(old.flatMap((entry) => [...entry.connections].map((close) => close())));
|
|
20
|
+
}
|
|
21
|
+
async fetch(request) {
|
|
22
|
+
if (this.closed)
|
|
23
|
+
return new Response(null, { status: 410 });
|
|
24
|
+
const generation = this.generation;
|
|
25
|
+
const path = new URL(request.url).pathname;
|
|
26
|
+
const browserRoute = /^\/browser\/sessions\/([^/]+)\/browser\/(events|claims|results)$/u.exec(path);
|
|
27
|
+
if (browserRoute === null)
|
|
28
|
+
return new Response("Not found", { status: 404 });
|
|
29
|
+
let sessionId;
|
|
30
|
+
try {
|
|
31
|
+
sessionId = decodeURIComponent(browserRoute[1] ?? "");
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return new Response("Not found", { status: 404 });
|
|
35
|
+
}
|
|
36
|
+
const route = browserRoute[2];
|
|
37
|
+
if (!sessionId || !route)
|
|
38
|
+
return new Response("Not found", { status: 404 });
|
|
39
|
+
let entry = this.hosts.get(sessionId);
|
|
40
|
+
if (entry === undefined) {
|
|
41
|
+
if (route !== "events")
|
|
42
|
+
return new Response("Not found", { status: 404 });
|
|
43
|
+
if (!(await this.hasSession(sessionId)))
|
|
44
|
+
return new Response("Not found", { status: 404 });
|
|
45
|
+
if (generation !== this.generation)
|
|
46
|
+
return new Response(null, { status: 404 });
|
|
47
|
+
entry = this.hosts.get(sessionId) ?? this.createHost(sessionId);
|
|
48
|
+
}
|
|
49
|
+
const target = new URL(request.url);
|
|
50
|
+
target.pathname = `/browser/${route}`;
|
|
51
|
+
if (route !== "events")
|
|
52
|
+
return entry.host.fetch(new Request(target, request));
|
|
53
|
+
return this.openEvents(new Request(target, request), sessionId, entry, generation);
|
|
54
|
+
}
|
|
55
|
+
/** Own the async transport opening until its stream joins the connection scope. */
|
|
56
|
+
async openEvents(request, sessionId, entry, generation) {
|
|
57
|
+
entry.browserConnections += 1;
|
|
58
|
+
try {
|
|
59
|
+
const response = await entry.host.fetch(request);
|
|
60
|
+
if (generation !== this.generation) {
|
|
61
|
+
// The maintained transport may finish opening after teardown. Its
|
|
62
|
+
// late scope belongs to the old generation and must never escape.
|
|
63
|
+
await response.body?.cancel();
|
|
64
|
+
this.releaseBrowserConnection(sessionId, entry);
|
|
65
|
+
return new Response(null, { status: 404 });
|
|
66
|
+
}
|
|
67
|
+
if (!response.ok || response.body === null) {
|
|
68
|
+
this.releaseBrowserConnection(sessionId, entry);
|
|
69
|
+
return response;
|
|
70
|
+
}
|
|
71
|
+
return this.trackBrowserConnection(response, sessionId, entry);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
this.releaseBrowserConnection(sessionId, entry);
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
createHost(sessionId) {
|
|
79
|
+
const entry = {
|
|
80
|
+
host: this.open(sessionId),
|
|
81
|
+
activeRuns: 0,
|
|
82
|
+
browserConnections: 0,
|
|
83
|
+
connections: new Set(),
|
|
84
|
+
};
|
|
85
|
+
this.hosts.set(sessionId, entry);
|
|
86
|
+
return entry;
|
|
87
|
+
}
|
|
88
|
+
acquire(sessionId) {
|
|
89
|
+
if (this.closed)
|
|
90
|
+
throw new Error("Browser host is closed");
|
|
91
|
+
const entry = this.hosts.get(sessionId) ?? this.createHost(sessionId);
|
|
92
|
+
entry.activeRuns += 1;
|
|
93
|
+
let released = false;
|
|
94
|
+
return {
|
|
95
|
+
modules: entry.host.modules,
|
|
96
|
+
release: () => {
|
|
97
|
+
if (released)
|
|
98
|
+
return;
|
|
99
|
+
released = true;
|
|
100
|
+
entry.activeRuns -= 1;
|
|
101
|
+
this.releaseIdleHost(sessionId, entry);
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
releaseBrowserConnection(sessionId, entry) {
|
|
106
|
+
entry.browserConnections -= 1;
|
|
107
|
+
this.releaseIdleHost(sessionId, entry);
|
|
108
|
+
}
|
|
109
|
+
releaseIdleHost(sessionId, entry) {
|
|
110
|
+
if (entry.activeRuns === 0 &&
|
|
111
|
+
entry.browserConnections === 0 &&
|
|
112
|
+
this.hosts.get(sessionId) === entry)
|
|
113
|
+
this.hosts.delete(sessionId);
|
|
114
|
+
}
|
|
115
|
+
trackBrowserConnection(response, sessionId, entry) {
|
|
116
|
+
const reader = response.body?.getReader();
|
|
117
|
+
if (reader === undefined) {
|
|
118
|
+
this.releaseBrowserConnection(sessionId, entry);
|
|
119
|
+
return response;
|
|
120
|
+
}
|
|
121
|
+
let released = false;
|
|
122
|
+
let controller;
|
|
123
|
+
const close = async () => {
|
|
124
|
+
if (released)
|
|
125
|
+
return;
|
|
126
|
+
release();
|
|
127
|
+
controller.close();
|
|
128
|
+
await reader.cancel();
|
|
129
|
+
};
|
|
130
|
+
const release = () => {
|
|
131
|
+
if (released)
|
|
132
|
+
return;
|
|
133
|
+
released = true;
|
|
134
|
+
entry.connections.delete(close);
|
|
135
|
+
this.releaseBrowserConnection(sessionId, entry);
|
|
136
|
+
};
|
|
137
|
+
entry.connections.add(close);
|
|
138
|
+
const body = new ReadableStream({
|
|
139
|
+
start: (streamController) => {
|
|
140
|
+
controller = streamController;
|
|
141
|
+
},
|
|
142
|
+
pull: async (controller) => {
|
|
143
|
+
try {
|
|
144
|
+
const next = await reader.read();
|
|
145
|
+
if (released)
|
|
146
|
+
return;
|
|
147
|
+
if (next.done) {
|
|
148
|
+
controller.close();
|
|
149
|
+
release();
|
|
150
|
+
}
|
|
151
|
+
else
|
|
152
|
+
controller.enqueue(next.value);
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (released)
|
|
156
|
+
return;
|
|
157
|
+
controller.error(error);
|
|
158
|
+
release();
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
cancel: async (reason) => {
|
|
162
|
+
release();
|
|
163
|
+
await reader.cancel(reason);
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
return new Response(body, {
|
|
167
|
+
status: response.status,
|
|
168
|
+
statusText: response.statusText,
|
|
169
|
+
headers: response.headers,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|