@fungi.computer/knapsack 0.0.2 → 0.2.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 +37 -4
- package/dist/files-patch.d.ts +20 -6
- package/dist/files-patch.js +11 -4
- package/dist/files-read.d.ts +115 -10
- package/dist/files-read.js +82 -12
- package/dist/files-search.d.ts +14 -0
- package/dist/files-search.js +52 -0
- package/dist/files.d.ts +59 -65
- package/dist/files.js +183 -97
- package/dist/index.d.ts +5 -1
- package/dist/index.js +10 -7
- 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 +207 -107
- package/docs/api/ui-browser.md +94 -0
- package/package.json +16 -5
package/dist/index.js
CHANGED
|
@@ -4,26 +4,27 @@ import * as Result from "effect/Result";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { files } from "./files.js";
|
|
6
6
|
import { mcpServers } from "./mcp.js";
|
|
7
|
+
import { ui } from "./ui.js";
|
|
7
8
|
const maximumCapabilities = 256;
|
|
8
9
|
const describeConcurrency = 8;
|
|
9
10
|
const defaultDescribeTimeoutMillis = 10_000;
|
|
10
11
|
const utf8 = new TextEncoder();
|
|
11
12
|
const jsonValueSchema = z.json();
|
|
12
|
-
const jsonSchemaDocument = z.custom((value) => jsonValueSchema.safeParse(value).success);
|
|
13
|
-
const capabilityNameSchema = z
|
|
13
|
+
const jsonSchemaDocument = z.lazy(() => z.custom((value) => jsonValueSchema.safeParse(value).success));
|
|
14
|
+
const capabilityNameSchema = z.lazy(() => z
|
|
14
15
|
.string()
|
|
15
16
|
.min(1)
|
|
16
17
|
.max(128)
|
|
17
|
-
.regex(/^[A-Za-z][A-Za-z0-9_]*$/);
|
|
18
|
-
const capabilitySchema = z.strictObject({
|
|
18
|
+
.regex(/^[A-Za-z][A-Za-z0-9_]*$/));
|
|
19
|
+
const capabilitySchema = z.lazy(() => z.strictObject({
|
|
19
20
|
id: capabilityNameSchema,
|
|
20
21
|
name: capabilityNameSchema,
|
|
21
22
|
kind: z.string().min(1).max(128),
|
|
22
23
|
description: z.string().min(1).max(2_048),
|
|
23
24
|
parameters: jsonSchemaDocument,
|
|
24
25
|
output: jsonSchemaDocument.optional(),
|
|
25
|
-
});
|
|
26
|
-
const capabilitiesSchema = z.array(capabilitySchema).max(maximumCapabilities);
|
|
26
|
+
}));
|
|
27
|
+
const capabilitiesSchema = z.lazy(() => z.array(capabilitySchema).max(maximumCapabilities));
|
|
27
28
|
function operationSchema(document) {
|
|
28
29
|
return z.fromJSONSchema(document).pipe(jsonValueSchema);
|
|
29
30
|
}
|
|
@@ -173,6 +174,8 @@ export const Knapsack = Object.freeze({
|
|
|
173
174
|
files,
|
|
174
175
|
/** Connect a changing list of remote MCP servers, reporting unavailable ones independently. */
|
|
175
176
|
mcpServers,
|
|
177
|
+
/** Connect the open App's live Whistle commands to each Agent Session. */
|
|
178
|
+
ui,
|
|
176
179
|
});
|
|
177
180
|
export { McpServerUnavailableError, } from "./mcp.js";
|
|
178
|
-
export { FilesEditError, filesEditFailureSchema, edit, readInputSchema, readOutputSchema, listInputSchema,
|
|
181
|
+
export { FilesEditError, filesEditFailureSchema, edit, readInputSchema, readOutputSchema, listInputSchema, globInputSchema, grepInputSchema, searchOutputSchema, readGuestOutputSchema, presentReadForGuest, editInputSchema, editOutputSchema, patchInputSchema, patchOutputSchema, } from "./files.js";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { WhistleRuntime } from "@fungi.computer/whistle";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Scope from "effect/Scope";
|
|
4
|
+
import { type BrowserRequestEnvelope, type BrowserResultEnvelope } from "./wire.js";
|
|
5
|
+
/** Browser-side platform failure before a correlated result is accepted. */
|
|
6
|
+
export declare class UiBrowserError extends Error {
|
|
7
|
+
constructor(message: string, options?: ErrorOptions);
|
|
8
|
+
}
|
|
9
|
+
/** One Agent Session's browser transport and the App's command runtime. */
|
|
10
|
+
export type BrowserConnectionOptions = Readonly<{
|
|
11
|
+
/** Session route that serves `/browser/{events,claims,results}`. */
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
/** The App shell's Whistle runtime; its agent projection is what the Agent sees. */
|
|
14
|
+
whistle: Pick<WhistleRuntime, "snapshot" | "execute">;
|
|
15
|
+
/** Host-authorized I/O; omitted for ordinary browser Fetch and EventSource. */
|
|
16
|
+
transport?: Readonly<{
|
|
17
|
+
/** Send claims and results with the host's existing admission. */
|
|
18
|
+
fetch: typeof globalThis.fetch;
|
|
19
|
+
/** Open the event stream with that same admission and a maintained SSE client. */
|
|
20
|
+
openEvents: (url: string) => EventTarget & {
|
|
21
|
+
/** Stop this connection's event stream when its owning scope closes. */
|
|
22
|
+
close(): void;
|
|
23
|
+
};
|
|
24
|
+
}>;
|
|
25
|
+
}>;
|
|
26
|
+
/** A live browser connection; close it when the selected Session changes. */
|
|
27
|
+
export type BrowserConnection = Readonly<{
|
|
28
|
+
/** Settles when the connection ends; rejects with {@link UiBrowserError} on failure. */
|
|
29
|
+
closed: Promise<void>;
|
|
30
|
+
/** Stop serving the Agent's requests. */
|
|
31
|
+
close: () => void;
|
|
32
|
+
}>;
|
|
33
|
+
/**
|
|
34
|
+
* Execute one claimed browser request through Whistle's single door.
|
|
35
|
+
*
|
|
36
|
+
* Claim is the dispatch point: the browser request carries no cancellation, so
|
|
37
|
+
* Whistle supplies its own never-aborted signal, and cancellation after this
|
|
38
|
+
* function starts cannot promise to undo a side effect.
|
|
39
|
+
*/
|
|
40
|
+
export declare function handleBrowserRequest(options: BrowserConnectionOptions, envelope: BrowserRequestEnvelope): Effect.Effect<Omit<BrowserResultEnvelope, "claimId">, UiBrowserError>;
|
|
41
|
+
/** Run one scope-owned EventSource connection and its Whistle request loop. */
|
|
42
|
+
export declare function runBrowserConnection(options: BrowserConnectionOptions): Effect.Effect<void, UiBrowserError, Scope.Scope>;
|
|
43
|
+
/** Start the named browser Promise facade over the Effect connection program. */
|
|
44
|
+
export declare function connectBrowser(options: BrowserConnectionOptions): BrowserConnection;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Scope from "effect/Scope";
|
|
3
|
+
import * as Stream from "effect/Stream";
|
|
4
|
+
import * as z from "zod";
|
|
5
|
+
import { BrowserClaimResultSchema, BrowserCommandRowSchema, BrowserRequestDeliverySchema, BrowserSettlementResultSchema, } from "./wire.js";
|
|
6
|
+
import { uiWireIds, BrowserExecutionOutcomeSchema } from "./wire.js";
|
|
7
|
+
/** Browser-side platform failure before a correlated result is accepted. */
|
|
8
|
+
export class UiBrowserError extends Error {
|
|
9
|
+
constructor(message, options) {
|
|
10
|
+
super(message, options);
|
|
11
|
+
this.name = "UiBrowserError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const decodeRequestEvent = (input) => Effect.try(() => BrowserRequestDeliverySchema.parse(JSON.parse(z.string().parse(input))));
|
|
15
|
+
const decodeClaimResult = (input) => Effect.try(() => BrowserClaimResultSchema.parse(input));
|
|
16
|
+
const decodeSettlementResult = (input) => Effect.try(() => BrowserSettlementResultSchema.parse(input));
|
|
17
|
+
function requestEventData(event) {
|
|
18
|
+
return event instanceof MessageEvent
|
|
19
|
+
? Effect.try(() => z.string().parse(event.data)).pipe(Effect.mapError(() => new UiBrowserError("Browser request event is invalid")))
|
|
20
|
+
: Effect.fail(new UiBrowserError("Browser request event is invalid"));
|
|
21
|
+
}
|
|
22
|
+
function projectCommands(whistle) {
|
|
23
|
+
return Effect.sync(() => BrowserCommandRowSchema.array().parse(whistle.snapshot().agent.map((row) => {
|
|
24
|
+
const sourceAction = row.action;
|
|
25
|
+
// Focus scope belongs to the local command graph, not the browser wire.
|
|
26
|
+
const projected = {
|
|
27
|
+
commandId: row.commandId,
|
|
28
|
+
sourceId: row.sourceId,
|
|
29
|
+
title: row.title,
|
|
30
|
+
category: row.category,
|
|
31
|
+
description: row.description ?? null,
|
|
32
|
+
order: row.order,
|
|
33
|
+
keybind: row.keybind ?? null,
|
|
34
|
+
availability: row.availability,
|
|
35
|
+
};
|
|
36
|
+
if (sourceAction === undefined)
|
|
37
|
+
return projected;
|
|
38
|
+
const action = {};
|
|
39
|
+
if (sourceAction.inputSchema !== undefined)
|
|
40
|
+
action.inputSchema = sourceAction.inputSchema;
|
|
41
|
+
if (sourceAction.outputSchema !== undefined)
|
|
42
|
+
action.outputSchema = sourceAction.outputSchema;
|
|
43
|
+
if (action.inputSchema === undefined &&
|
|
44
|
+
action.outputSchema === undefined)
|
|
45
|
+
return projected;
|
|
46
|
+
return { ...projected, action };
|
|
47
|
+
})));
|
|
48
|
+
}
|
|
49
|
+
function projectOutcome(outcome) {
|
|
50
|
+
const parsed = BrowserExecutionOutcomeSchema.safeParse(outcome);
|
|
51
|
+
return {
|
|
52
|
+
type: "execute",
|
|
53
|
+
outcome: parsed.success
|
|
54
|
+
? parsed.data
|
|
55
|
+
: {
|
|
56
|
+
status: "failed",
|
|
57
|
+
error: {
|
|
58
|
+
code: "invalid_command_result",
|
|
59
|
+
message: "Command result is not valid JSON data",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Execute one claimed browser request through Whistle's single door.
|
|
66
|
+
*
|
|
67
|
+
* Claim is the dispatch point: the browser request carries no cancellation, so
|
|
68
|
+
* Whistle supplies its own never-aborted signal, and cancellation after this
|
|
69
|
+
* function starts cannot promise to undo a side effect.
|
|
70
|
+
*/
|
|
71
|
+
export function handleBrowserRequest(options, envelope) {
|
|
72
|
+
if (envelope.request.type === "discover")
|
|
73
|
+
return projectCommands(options.whistle).pipe(Effect.map((commands) => ({
|
|
74
|
+
requestId: envelope.requestId,
|
|
75
|
+
result: { type: "discover", commands },
|
|
76
|
+
})));
|
|
77
|
+
const commandId = envelope.request.commandId;
|
|
78
|
+
const argumentsValue = "arguments" in envelope.request ? envelope.request.arguments : undefined;
|
|
79
|
+
return Effect.tryPromise({
|
|
80
|
+
try: () => options.whistle.execute(commandId, argumentsValue === undefined
|
|
81
|
+
? { origin: "agent" }
|
|
82
|
+
: { origin: "agent", arguments: argumentsValue }),
|
|
83
|
+
catch: () => new UiBrowserError("Whistle execution rejected"),
|
|
84
|
+
}).pipe(Effect.map((outcome) => ({
|
|
85
|
+
requestId: envelope.requestId,
|
|
86
|
+
result: projectOutcome(outcome),
|
|
87
|
+
})));
|
|
88
|
+
}
|
|
89
|
+
function claimRequest(options, delivery) {
|
|
90
|
+
const attemptId = uiWireIds.attempt.generate();
|
|
91
|
+
const claimOnce = () => Effect.tryPromise({
|
|
92
|
+
try: (signal) => (options.transport?.fetch ?? globalThis.fetch)(`${options.baseUrl}/browser/claims`, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: { "Content-Type": "application/json" },
|
|
95
|
+
body: JSON.stringify({
|
|
96
|
+
attemptId,
|
|
97
|
+
connectionId: delivery.connectionId,
|
|
98
|
+
requestId: delivery.requestId,
|
|
99
|
+
}),
|
|
100
|
+
signal,
|
|
101
|
+
}),
|
|
102
|
+
catch: () => new UiBrowserError("Browser claim request failed"),
|
|
103
|
+
}).pipe(Effect.flatMap((response) => response.ok
|
|
104
|
+
? Effect.tryPromise({
|
|
105
|
+
try: () => response.json(),
|
|
106
|
+
catch: () => new UiBrowserError("Browser claim response is invalid"),
|
|
107
|
+
})
|
|
108
|
+
: Effect.fail(new UiBrowserError(`Browser claim was rejected with status ${String(response.status)}`))), Effect.flatMap((input) => Effect.try(() => z.record(z.string(), z.unknown()).parse(input)).pipe(Effect.flatMap(decodeClaimResult))), Effect.mapError(() => new UiBrowserError("Browser claim response is invalid")));
|
|
109
|
+
const waitForClaim = () => claimOnce().pipe(Effect.flatMap((claim) => claim.status === "waiting"
|
|
110
|
+
? Effect.sleep(claim.retryAfterMs).pipe(Effect.andThen(Effect.suspend(waitForClaim)))
|
|
111
|
+
: Effect.succeed(claim)));
|
|
112
|
+
return Effect.suspend(waitForClaim);
|
|
113
|
+
}
|
|
114
|
+
function postResult(options, result) {
|
|
115
|
+
return Effect.tryPromise({
|
|
116
|
+
try: (signal) => (options.transport?.fetch ?? globalThis.fetch)(`${options.baseUrl}/browser/results`, {
|
|
117
|
+
method: "POST",
|
|
118
|
+
headers: { "Content-Type": "application/json" },
|
|
119
|
+
body: JSON.stringify(result),
|
|
120
|
+
signal,
|
|
121
|
+
}),
|
|
122
|
+
catch: () => new UiBrowserError("Browser result request failed"),
|
|
123
|
+
}).pipe(Effect.flatMap((response) => response.ok
|
|
124
|
+
? Effect.tryPromise({
|
|
125
|
+
try: () => response.json(),
|
|
126
|
+
catch: () => new UiBrowserError("Browser settlement response is invalid"),
|
|
127
|
+
}).pipe(Effect.flatMap((input) => Effect.try(() => z.record(z.string(), z.unknown()).parse(input)).pipe(Effect.flatMap(decodeSettlementResult))), Effect.mapError(() => new UiBrowserError("Browser settlement response is invalid")), Effect.asVoid)
|
|
128
|
+
: Effect.fail(new UiBrowserError(`Browser result was rejected with status ${String(response.status)}`))));
|
|
129
|
+
}
|
|
130
|
+
function handleRequestEvent(options, event) {
|
|
131
|
+
return requestEventData(event).pipe(Effect.flatMap(decodeRequestEvent), Effect.mapError(() => new UiBrowserError("Browser request payload is invalid")), Effect.flatMap((delivery) => claimRequest(options, delivery).pipe(Effect.flatMap((claim) => claim.status !== "claimed"
|
|
132
|
+
? Effect.void
|
|
133
|
+
: handleBrowserRequest(options, delivery).pipe(Effect.flatMap((result) => postResult(options, {
|
|
134
|
+
...result,
|
|
135
|
+
claimId: claim.claimId,
|
|
136
|
+
})))))));
|
|
137
|
+
}
|
|
138
|
+
/** Run one scope-owned EventSource connection and its Whistle request loop. */
|
|
139
|
+
export function runBrowserConnection(options) {
|
|
140
|
+
return Effect.acquireRelease(Effect.try({
|
|
141
|
+
try: () => {
|
|
142
|
+
const url = `${options.baseUrl}/browser/events`;
|
|
143
|
+
return options.transport === undefined
|
|
144
|
+
? new EventSource(url)
|
|
145
|
+
: options.transport.openEvents(url);
|
|
146
|
+
},
|
|
147
|
+
catch: () => new UiBrowserError("Browser event stream could not open"),
|
|
148
|
+
}), (source) => Effect.sync(() => {
|
|
149
|
+
source.close();
|
|
150
|
+
})).pipe(Effect.flatMap((source) => Stream.fromEventListener(source, "browser-request", {
|
|
151
|
+
bufferSize: 1,
|
|
152
|
+
}).pipe(Stream.mapEffect((event) => handleRequestEvent(options, event)), Stream.runDrain)));
|
|
153
|
+
}
|
|
154
|
+
/** Start the named browser Promise facade over the Effect connection program. */
|
|
155
|
+
export function connectBrowser(options) {
|
|
156
|
+
const cancellation = new AbortController();
|
|
157
|
+
const closed = Effect.runPromise(Effect.scoped(runBrowserConnection(options)), {
|
|
158
|
+
signal: cancellation.signal,
|
|
159
|
+
}).catch((cause) => {
|
|
160
|
+
if (!cancellation.signal.aborted)
|
|
161
|
+
throw new UiBrowserError("Browser connection failed", {
|
|
162
|
+
cause,
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
return Object.freeze({
|
|
166
|
+
closed,
|
|
167
|
+
close: () => {
|
|
168
|
+
cancellation.abort();
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
@@ -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;
|