authorgravatar for 51714798+juliusmarminge@users.noreply.github.comJulius Marminge <51714798+juliusmarminge@users.noreply.github.com> 2026-06-12 18:19:01-07:00
committergravatar for me@theo.liTheo Browne <me@theo.li> 2026-06-13 22:18:37-07:00
logd8fe3d72c82cc3cc6a6e9275494f5e5377b058a6
tree695738c2f90e2de2782ac32d727cb9f5495829e1
parent70ec39ae8094d06eb7a241a007bbc2b0892c2896

Scope preview listeners and control sessions

- Tie preview and debugger listeners to Effect scopes - Factor shared automation helpers for snapshot and input handling - Improve cleanup for browser preview sessions and port scanning

13 files changed, 853 insertions(+), 747 deletions(-)

apps/desktop/src/preview/Manager.ts+544-491
......@@ -48,7 +48,7 @@ import * as Path from "effect/Path";
4848import * as Ref from "effect/Ref";
4949import * as Schema from "effect/Schema";
5050import * as Semaphore from "effect/Semaphore";
51import type * as Scope from "effect/Scope";
51import * as Scope from "effect/Scope";
5252import * as SynchronizedRef from "effect/SynchronizedRef";
5353
5454import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
......@@ -247,10 +247,7 @@ type PreviewInputSignal =
247247 | { readonly kind: "key"; readonly key: string; readonly code: string };
248248
249249interface ManagedListeners {
250 navigate: () => void;
251 failed: (event: Event, code: number, description: string) => void;
252 humanInput: (_event: unknown, signal?: unknown) => void;
253 beforeInput: (event: Electron.Event, input: Electron.Input) => void;
250 readonly scope: Scope.Closeable;
254251}
255252
256253interface PickSession {
......@@ -260,6 +257,7 @@ interface PickSession {
260257interface BrowserControlSession {
261258 readonly webContentsId: number;
262259 readonly semaphore: Semaphore.Semaphore;
260 readonly scope: Scope.Closeable;
263261 readonly onMessage: (
264262 event: Electron.Event,
265263 method: string,
......@@ -339,6 +337,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
339337) {
340338 const fileSystem = yield* FileSystem.FileSystem;
341339 const path = yield* Path.Path;
340 const parentScope = yield* Scope.Scope;
342341 const context = yield* Effect.context<never>();
343342 const runFork = Effect.runForkWith(context);
344343 const resolvedArtifactDirectory = path.resolve(artifactDirectory);
......@@ -622,16 +621,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
622621 copy.delete(webContentsId);
623622 }),
624623 ]);
624 if (control) {
625 yield* Scope.close(control.scope, Exit.void).pipe(Effect.ignore);
626 return;
627 }
625628 yield* Ref.update(diagnosticsRef, (diagnostics) =>
626629 replaceMap(diagnostics, (copy) => {
627630 copy.delete(webContentsId);
628631 }),
629632 );
630 const wc = webContents.fromId(webContentsId);
631 if (!wc || wc.isDestroyed()) return;
632 if (control) wc.debugger.off("message", control.onMessage);
633 if (!wc.debugger.isAttached()) return;
634 yield* attempt("detachControlSession", () => wc.debugger.detach()).pipe(Effect.ignore);
635633 });
636634
637635 const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* (
......@@ -662,87 +660,103 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
662660 ),
663661 );
664662 }
665 return Effect.gen(function* () {
663 const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () {
666664 const semaphore = yield* Semaphore.make(1);
665 const scope = yield* Scope.fork(parentScope, "sequential");
666 const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")(function* (
667 method: string,
668 params: Record<string, unknown>,
669 ) {
670 if (method === "Page.screencastFrame") {
671 const sessionId = params["sessionId"];
672 if (typeof sessionId === "number") {
673 yield* attemptPromise("ackScreencastFrame", () =>
674 wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }),
675 ).pipe(Effect.ignore);
676 }
677 const tabId = yield* tabIdForWebContents(wc.id);
678 const metadata =
679 typeof params["metadata"] === "object" && params["metadata"] !== null
680 ? (params["metadata"] as Record<string, unknown>)
681 : {};
682 if (tabId && typeof params["data"] === "string") {
683 const receivedAt = yield* currentIso;
684 const listeners = yield* Ref.get(recordingFrameListenersRef);
685 const frame: DesktopPreviewRecordingFrame = {
686 tabId,
687 data: params["data"],
688 width: typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0,
689 height: typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0,
690 receivedAt,
691 };
692 yield* Effect.forEach(
693 listeners,
694 (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore),
695 { discard: true },
696 );
697 }
698 }
699 yield* captureDiagnosticMessage(wc.id, method, params);
700 });
667701 const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => {
668 runFork(
669 Effect.gen(function* () {
670 if (method === "Page.screencastFrame") {
671 const sessionId = params["sessionId"];
672 if (typeof sessionId === "number") {
673 yield* attemptPromise("ackScreencastFrame", () =>
674 wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }),
675 ).pipe(Effect.ignore);
676 }
677 const tabId = yield* tabIdForWebContents(wc.id);
678 const metadata =
679 typeof params["metadata"] === "object" && params["metadata"] !== null
680 ? (params["metadata"] as Record<string, unknown>)
681 : {};
682 if (tabId && typeof params["data"] === "string") {
683 const receivedAt = yield* currentIso;
684 const listeners = yield* Ref.get(recordingFrameListenersRef);
685 const frame: DesktopPreviewRecordingFrame = {
686 tabId,
687 data: params["data"],
688 width:
689 typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0,
690 height:
691 typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0,
692 receivedAt,
693 };
694 yield* Effect.forEach(
695 listeners,
696 (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore),
697 { discard: true },
698 );
699 }
700 }
701 yield* captureDiagnosticMessage(wc.id, method, params);
702 }),
703 );
702 runFork(handleDebuggerMessage(method, params));
704703 };
705 const control: BrowserControlSession = { webContentsId: wc.id, semaphore, onMessage };
706 yield* Ref.update(diagnosticsRef, (diagnostics) =>
707 replaceMap(diagnostics, (copy) => {
708 copy.set(wc.id, {
709 consoleEntries: [],
710 networkEntries: [],
711 requests: new Map(),
712 });
713 }),
714 );
715 yield* attempt("attachDebuggerListeners", () => {
716 wc.debugger.on("message", onMessage);
717 wc.debugger.attach("1.3");
718 });
719 yield* Effect.all(
720 ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map((method) =>
721 attemptPromise("initializeDebugger", () => wc.debugger.sendCommand(method)),
722 ),
723 { concurrency: "unbounded", discard: true },
724 ).pipe(
725 Effect.tapError(() =>
726 Effect.all([
704 yield* Scope.addFinalizer(
705 scope,
706 Effect.all(
707 [
727708 Ref.update(diagnosticsRef, (diagnostics) =>
728709 replaceMap(diagnostics, (copy) => {
729710 copy.delete(wc.id);
730711 }),
731712 ),
732 attempt("detachFailedDebugger", () => {
713 attempt("detachControlSession", () => {
733714 wc.debugger.off("message", onMessage);
734715 if (wc.debugger.isAttached()) wc.debugger.detach();
735716 }).pipe(Effect.ignore),
736 ]).pipe(Effect.asVoid),
717 ],
718 { discard: true },
737719 ),
738720 );
739 return [
740 control,
741 replaceMap(sessions, (copy) => {
742 copy.set(wc.id, control);
743 }),
744 ] as const;
721 const control: BrowserControlSession = {
722 webContentsId: wc.id,
723 semaphore,
724 scope,
725 onMessage,
726 };
727 const initialize = Effect.fn("PreviewManager.initializeControlSession")(function* () {
728 yield* Ref.update(diagnosticsRef, (diagnostics) =>
729 replaceMap(diagnostics, (copy) => {
730 copy.set(wc.id, {
731 consoleEntries: [],
732 networkEntries: [],
733 requests: new Map(),
734 });
735 }),
736 );
737 yield* attempt("attachDebuggerListeners", () => {
738 wc.debugger.on("message", onMessage);
739 wc.debugger.attach("1.3");
740 });
741 yield* Effect.all(
742 ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map(
743 (method) =>
744 attemptPromise("initializeDebugger", () => wc.debugger.sendCommand(method)),
745 ),
746 { concurrency: "unbounded", discard: true },
747 );
748 return [
749 control,
750 replaceMap(sessions, (copy) => {
751 copy.set(wc.id, control);
752 }),
753 ] as const;
754 });
755 return yield* initialize().pipe(
756 Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)),
757 );
745758 });
759 return createControlSession();
746760 });
747761 });
748762
......@@ -906,25 +920,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
906920 const detachListeners = Effect.fn("PreviewManager.detachListeners")(function* (
907921 webContentsId: number,
908922 ) {
909 const handlers = yield* Ref.modify(attachedRef, (attached) => [
923 const managed = yield* Ref.modify(attachedRef, (attached) => [
910924 attached.get(webContentsId),
911925 replaceMap(attached, (copy) => {
912926 copy.delete(webContentsId);
913927 }),
914928 ]);
915 if (!handlers) return;
916 const wc = webContents.fromId(webContentsId);
917 if (!wc || wc.isDestroyed()) return;
918 yield* attempt("detachListeners", () => {
919 wc.off("did-navigate", handlers.navigate);
920 wc.off("did-navigate-in-page", handlers.navigate);
921 wc.off("page-title-updated", handlers.navigate);
922 wc.off("did-start-loading", handlers.navigate);
923 wc.off("did-stop-loading", handlers.navigate);
924 wc.off("did-fail-load", handlers.failed as never);
925 wc.off("before-input-event", handlers.beforeInput);
926 wc.ipc.off(HUMAN_INPUT_CHANNEL, handlers.humanInput);
927 }).pipe(Effect.ignore);
929 if (managed) yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore);
928930 });
929931
930932 const isAppShortcut = (input: Electron.Input): boolean =>
......@@ -987,17 +989,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
987989 tabId: string,
988990 wc: Electron.WebContents,
989991 ) {
990 const sync = () =>
991 runFork(
992 Effect.gen(function* () {
993 if (wc.isDestroyed()) return;
994 yield* update(tabId, {
995 navStatus: computeNavStatus(wc),
996 canGoBack: wc.navigationHistory.canGoBack(),
997 canGoForward: wc.navigationHistory.canGoForward(),
998 });
999 }),
1000 );
992 const scope = yield* Scope.fork(parentScope, "sequential");
993 const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* () {
994 if (wc.isDestroyed()) return;
995 yield* update(tabId, {
996 navStatus: computeNavStatus(wc),
997 canGoBack: wc.navigationHistory.canGoBack(),
998 canGoForward: wc.navigationHistory.canGoForward(),
999 });
1000 });
1001 const sync = () => runFork(syncState());
10011002 const failed = (_event: Event, code: number, description: string): void => {
10021003 if (code === -3) return;
10031004 runFork(
......@@ -1012,73 +1013,85 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
10121013 }),
10131014 );
10141015 };
1015 const humanInput = (_event: unknown, rawSignal?: unknown): void => {
1016 runFork(
1017 Effect.gen(function* () {
1018 if (
1019 isPreviewInputSignal(rawSignal) &&
1020 (yield* consumeExpectedAgentInput(tabId, rawSignal))
1021 ) {
1022 return;
1023 }
1024 yield* Ref.update(controlEpochRef, (epochs) =>
1025 replaceMap(epochs, (copy) => {
1026 copy.set(tabId, (epochs.get(tabId) ?? 0) + 1);
1027 }),
1028 );
1029 yield* update(tabId, { controller: "human" });
1030 yield* Effect.sleep(750);
1031 const tabs = yield* SynchronizedRef.get(tabsRef);
1032 if (tabs.get(tabId)?.controller === "human") {
1033 yield* update(tabId, { controller: "none" });
1034 }
1016 const handleHumanInput = Effect.fn("PreviewManager.handleHumanInput")(function* (
1017 rawSignal?: unknown,
1018 ) {
1019 if (isPreviewInputSignal(rawSignal) && (yield* consumeExpectedAgentInput(tabId, rawSignal))) {
1020 return;
1021 }
1022 yield* Ref.update(controlEpochRef, (epochs) =>
1023 replaceMap(epochs, (copy) => {
1024 copy.set(tabId, (epochs.get(tabId) ?? 0) + 1);
10351025 }),
10361026 );
1027 yield* update(tabId, { controller: "human" });
1028 yield* Effect.sleep(750);
1029 const tabs = yield* SynchronizedRef.get(tabsRef);
1030 if (tabs.get(tabId)?.controller === "human") {
1031 yield* update(tabId, { controller: "none" });
1032 }
1033 });
1034 const humanInput = (_event: unknown, rawSignal?: unknown): void => {
1035 runFork(handleHumanInput(rawSignal));
10371036 };
1037 const forwardShortcut = Effect.fn("PreviewManager.forwardShortcut")(function* (
1038 event: Electron.Event,
1039 input: Electron.Input,
1040 ) {
1041 const mainWindow = yield* Ref.get(mainWindowRef);
1042 if (!isAppShortcut(input) || Option.isNone(mainWindow) || mainWindow.value.isDestroyed()) {
1043 return;
1044 }
1045 event.preventDefault();
1046 mainWindow.value.webContents.sendInputEvent({
1047 type: "keyDown",
1048 keyCode: input.key,
1049 modifiers: [
1050 ...(input.meta ? (["meta"] as const) : []),
1051 ...(input.shift ? (["shift"] as const) : []),
1052 ...(input.control ? (["control"] as const) : []),
1053 ...(input.alt ? (["alt"] as const) : []),
1054 ],
1055 });
1056 });
10381057 const beforeInput = (event: Electron.Event, input: Electron.Input): void => {
1039 runFork(
1040 Effect.gen(function* () {
1041 const mainWindow = yield* Ref.get(mainWindowRef);
1042 if (
1043 !isAppShortcut(input) ||
1044 Option.isNone(mainWindow) ||
1045 mainWindow.value.isDestroyed()
1046 ) {
1047 return;
1048 }
1049 event.preventDefault();
1050 mainWindow.value.webContents.sendInputEvent({
1051 type: "keyDown",
1052 keyCode: input.key,
1053 modifiers: [
1054 ...(input.meta ? (["meta"] as const) : []),
1055 ...(input.shift ? (["shift"] as const) : []),
1056 ...(input.control ? (["control"] as const) : []),
1057 ...(input.alt ? (["alt"] as const) : []),
1058 ],
1059 });
1060 }),
1061 );
1058 runFork(forwardShortcut(event, input));
10621059 };
1063 yield* attempt("attachListeners", () => {
1064 wc.on("did-navigate", sync);
1065 wc.on("did-navigate-in-page", sync);
1066 wc.on("page-title-updated", sync);
1067 wc.on("did-start-loading", sync);
1068 wc.on("did-stop-loading", sync);
1069 wc.on("did-fail-load", failed as never);
1070 wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);
1071 wc.setWindowOpenHandler(({ url }) => {
1072 runFork(attemptPromise("openPreviewWindow", () => wc.loadURL(url)).pipe(Effect.ignore));
1073 return { action: "deny" };
1060 yield* Scope.addFinalizer(
1061 scope,
1062 attempt("detachListeners", () => {
1063 wc.off("did-navigate", sync);
1064 wc.off("did-navigate-in-page", sync);
1065 wc.off("page-title-updated", sync);
1066 wc.off("did-start-loading", sync);
1067 wc.off("did-stop-loading", sync);
1068 wc.off("did-fail-load", failed as never);
1069 wc.off("before-input-event", beforeInput);
1070 wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput);
1071 }).pipe(Effect.ignore),
1072 );
1073 const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () {
1074 yield* attempt("attachListeners", () => {
1075 wc.on("did-navigate", sync);
1076 wc.on("did-navigate-in-page", sync);
1077 wc.on("page-title-updated", sync);
1078 wc.on("did-start-loading", sync);
1079 wc.on("did-stop-loading", sync);
1080 wc.on("did-fail-load", failed as never);
1081 wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);
1082 wc.setWindowOpenHandler(({ url }) => {
1083 runFork(attemptPromise("openPreviewWindow", () => wc.loadURL(url)).pipe(Effect.ignore));
1084 return { action: "deny" };
1085 });
1086 wc.on("before-input-event", beforeInput);
10741087 });
1075 wc.on("before-input-event", beforeInput);
1088 yield* Ref.update(attachedRef, (attached) =>
1089 replaceMap(attached, (copy) => {
1090 copy.set(wc.id, { scope });
1091 }),
1092 );
10761093 });
1077 yield* Ref.update(attachedRef, (attached) =>
1078 replaceMap(attached, (copy) => {
1079 copy.set(wc.id, { navigate: sync, failed, humanInput, beforeInput });
1080 }),
1081 );
1094 yield* install().pipe(Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)));
10821095 });
10831096
10841097 const setMainWindow = Effect.fn("PreviewManager.setMainWindow")(function* (
......@@ -1271,7 +1284,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
12711284 const annotationTheme = yield* Ref.get(annotationThemeRef);
12721285 return yield* Effect.callback<PreviewAnnotationPayload | null, PreviewManagerError>(
12731286 (resume) => {
1274 const cleanup = Effect.gen(function* () {
1287 const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () {
12751288 yield* attempt("pickElement.cleanup", () => {
12761289 wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage);
12771290 wc.off("destroyed", onDestroyed);
......@@ -1283,18 +1296,19 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
12831296 }),
12841297 );
12851298 });
1299 const settlePick = Effect.fn("PreviewManager.settlePickElement")(function* (
1300 payload: PreviewAnnotationPayload | null,
1301 ) {
1302 const active = (yield* Ref.get(pickSessionsRef)).get(tabId);
1303 if (!active || active.cancel !== cancel) return;
1304 yield* cleanup();
1305 resume(Effect.succeed(payload));
1306 });
12861307 const settle = (payload: PreviewAnnotationPayload | null) => {
1287 runFork(
1288 Effect.gen(function* () {
1289 const active = (yield* Ref.get(pickSessionsRef)).get(tabId);
1290 if (!active || active.cancel !== cancel) return;
1291 yield* cleanup;
1292 resume(Effect.succeed(payload));
1293 }),
1294 );
1308 runFork(settlePick(payload));
12951309 };
1296 const cancel = Effect.gen(function* () {
1297 yield* cleanup;
1310 const cancelPickSession = Effect.fn("PreviewManager.cancelPickSession")(function* () {
1311 yield* cleanup();
12981312 const tabs = yield* SynchronizedRef.get(tabsRef);
12991313 const activeTab = tabs.get(tabId);
13001314 if (activeTab?.webContentsId != null) {
......@@ -1307,6 +1321,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
13071321 }
13081322 resume(Effect.succeed(null));
13091323 });
1324 const cancel = cancelPickSession();
13101325 const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => {
13111326 const payload = args[0];
13121327 if (!isPreviewAnnotationPayload(payload)) {
......@@ -1330,24 +1345,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
13301345 };
13311346 const onDestroyed = () => settle(null);
13321347 const onNavigated = () => settle(null);
1348 const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () {
1349 yield* attempt("pickElement.register", () => {
1350 wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage);
1351 wc.once("destroyed", onDestroyed);
1352 wc.once("did-start-navigation", onNavigated);
1353 if (!wc.isFocused()) wc.focus();
1354 wc.send(START_PICK_CHANNEL, annotationTheme);
1355 });
1356 yield* Ref.update(pickSessionsRef, (sessions) =>
1357 replaceMap(sessions, (copy) => {
1358 copy.set(tabId, { cancel });
1359 }),
1360 );
1361 });
13331362 runFork(
1334 Effect.gen(function* () {
1335 yield* attempt("pickElement.register", () => {
1336 wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage);
1337 wc.once("destroyed", onDestroyed);
1338 wc.once("did-start-navigation", onNavigated);
1339 if (!wc.isFocused()) wc.focus();
1340 wc.send(START_PICK_CHANNEL, annotationTheme);
1341 });
1342 yield* Ref.update(pickSessionsRef, (sessions) =>
1343 replaceMap(sessions, (copy) => {
1344 copy.set(tabId, { cancel });
1345 }),
1346 );
1347 }).pipe(
1363 registerPickElement().pipe(
13481364 Effect.catch((error: PreviewManagerError) => {
13491365 resume(Effect.fail(error));
1350 return cleanup;
1366 return cleanup();
13511367 }),
13521368 ),
13531369 );
......@@ -1401,6 +1417,19 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
14011417 };
14021418 });
14031419
1420 const startScreencast = Effect.fn("PreviewManager.startScreencast")(function* (
1421 send: SendCommand,
1422 ) {
1423 yield* send("Page.enable");
1424 yield* send("Page.startScreencast", {
1425 format: "jpeg",
1426 quality: 80,
1427 maxWidth: 1600,
1428 maxHeight: 1200,
1429 everyNthFrame: 1,
1430 });
1431 });
1432
14041433 const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) {
14051434 const recordingTabId = yield* Ref.get(recordingTabIdRef);
14061435 if (Option.isSome(recordingTabId) && recordingTabId.value !== tabId) {
......@@ -1410,18 +1439,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
14101439 );
14111440 }
14121441 const wc = yield* requireWebContents(tabId);
1413 yield* withControlSession(tabId, wc, "recording.start", (send) =>
1414 Effect.gen(function* () {
1415 yield* send("Page.enable");
1416 yield* send("Page.startScreencast", {
1417 format: "jpeg",
1418 quality: 80,
1419 maxWidth: 1600,
1420 maxHeight: 1200,
1421 everyNthFrame: 1,
1422 });
1423 }),
1424 );
1442 yield* withControlSession(tabId, wc, "recording.start", startScreencast);
14251443 yield* Ref.set(recordingTabIdRef, Option.some(tabId));
14261444 });
14271445
......@@ -1493,104 +1511,108 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
14931511 };
14941512 });
14951513
1514 const captureAutomationSnapshot = Effect.fn("PreviewManager.captureAutomationSnapshot")(
1515 function* (tabId: string, wc: Electron.WebContents, send: SendCommand) {
1516 yield* Effect.all([send("Runtime.enable"), send("Accessibility.enable")], {
1517 concurrency: 2,
1518 discard: true,
1519 });
1520 const page = yield* evaluateWithDebugger<{
1521 url: string;
1522 title: string;
1523 loading: boolean;
1524 visibleText: string;
1525 interactiveElements: PreviewAutomationSnapshot["interactiveElements"];
1526 }>(
1527 send,
1528 `(() => {
1529 const selectorFor = (element) => {
1530 if (element.id) return "#" + CSS.escape(element.id);
1531 for (const attribute of ["data-testid", "name"]) {
1532 const value = element.getAttribute(attribute);
1533 if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]";
1534 }
1535 const buildParts = (current, parts = []) => {
1536 if (!current || current.nodeType !== Node.ELEMENT_NODE || parts.length >= 8) {
1537 return parts;
1538 }
1539 const parent = current.parentElement;
1540 const siblings = parent
1541 ? Array.from(parent.children).filter((child) => child.tagName === current.tagName)
1542 : [];
1543 const base = current.tagName.toLowerCase();
1544 const part = siblings.length > 1
1545 ? base + ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")"
1546 : base;
1547 return buildParts(parent, [part, ...parts]);
1548 };
1549 return buildParts(element).join(" > ");
1550 };
1551 const visible = (element) => {
1552 const style = getComputedStyle(element);
1553 const rect = element.getBoundingClientRect();
1554 return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
1555 };
1556 const elements = Array.from(document.querySelectorAll(
1557 "a[href],button,input,textarea,select,[role],[tabindex]"
1558 )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => {
1559 const rect = element.getBoundingClientRect();
1560 return {
1561 tag: element.tagName.toLowerCase(),
1562 role: element.getAttribute("role"),
1563 name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "",
1564 selector: selectorFor(element),
1565 x: rect.x,
1566 y: rect.y,
1567 width: rect.width,
1568 height: rect.height
1569 };
1570 });
1571 return {
1572 url: location.href,
1573 title: document.title,
1574 loading: document.readyState !== "complete",
1575 visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}),
1576 interactiveElements: elements
1577 };
1578 })()`,
1579 true,
1580 );
1581 const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([
1582 send("Accessibility.getFullAXTree"),
1583 attemptPromise("automationSnapshot.capturePage", () => wc.capturePage()),
1584 Ref.get(diagnosticsRef),
1585 Ref.get(actionTimelineRef),
1586 ]);
1587 const sourceSize = sourceImage.getSize();
1588 const image =
1589 sourceSize.width > MAX_SCREENSHOT_WIDTH
1590 ? sourceImage.resize({ width: MAX_SCREENSHOT_WIDTH })
1591 : sourceImage;
1592 const size = image.getSize();
1593 const browserDiagnostics = diagnostics.get(wc.id);
1594 return {
1595 ...page,
1596 accessibilityTree: accessibility,
1597 consoleEntries: [...(browserDiagnostics?.consoleEntries ?? [])],
1598 networkEntries: [...(browserDiagnostics?.networkEntries ?? [])],
1599 actionTimeline: [...(timelines.get(tabId) ?? [])],
1600 screenshot: {
1601 mimeType: "image/png" as const,
1602 data: image.toPNG().toString("base64"),
1603 width: size.width,
1604 height: size.height,
1605 },
1606 };
1607 },
1608 );
1609
14961610 const automationSnapshot = Effect.fn("PreviewManager.automationSnapshot")(function* (
14971611 tabId: string,
14981612 ) {
14991613 const wc = yield* requireWebContents(tabId);
15001614 return yield* withControlSession(tabId, wc, "snapshot", (send) =>
1501 Effect.gen(function* () {
1502 yield* Effect.all([send("Runtime.enable"), send("Accessibility.enable")], {
1503 concurrency: 2,
1504 discard: true,
1505 });
1506 const page = yield* evaluateWithDebugger<{
1507 url: string;
1508 title: string;
1509 loading: boolean;
1510 visibleText: string;
1511 interactiveElements: PreviewAutomationSnapshot["interactiveElements"];
1512 }>(
1513 send,
1514 `(() => {
1515 const selectorFor = (element) => {
1516 if (element.id) return "#" + CSS.escape(element.id);
1517 for (const attribute of ["data-testid", "name"]) {
1518 const value = element.getAttribute(attribute);
1519 if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]";
1520 }
1521 const buildParts = (current, parts = []) => {
1522 if (!current || current.nodeType !== Node.ELEMENT_NODE || parts.length >= 8) {
1523 return parts;
1524 }
1525 const parent = current.parentElement;
1526 const siblings = parent
1527 ? Array.from(parent.children).filter((child) => child.tagName === current.tagName)
1528 : [];
1529 const base = current.tagName.toLowerCase();
1530 const part = siblings.length > 1
1531 ? base + ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")"
1532 : base;
1533 return buildParts(parent, [part, ...parts]);
1534 };
1535 return buildParts(element).join(" > ");
1536 };
1537 const visible = (element) => {
1538 const style = getComputedStyle(element);
1539 const rect = element.getBoundingClientRect();
1540 return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
1541 };
1542 const elements = Array.from(document.querySelectorAll(
1543 "a[href],button,input,textarea,select,[role],[tabindex]"
1544 )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => {
1545 const rect = element.getBoundingClientRect();
1546 return {
1547 tag: element.tagName.toLowerCase(),
1548 role: element.getAttribute("role"),
1549 name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "",
1550 selector: selectorFor(element),
1551 x: rect.x,
1552 y: rect.y,
1553 width: rect.width,
1554 height: rect.height
1555 };
1556 });
1557 return {
1558 url: location.href,
1559 title: document.title,
1560 loading: document.readyState !== "complete",
1561 visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}),
1562 interactiveElements: elements
1563 };
1564 })()`,
1565 true,
1566 );
1567 const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([
1568 send("Accessibility.getFullAXTree"),
1569 attemptPromise("automationSnapshot.capturePage", () => wc.capturePage()),
1570 Ref.get(diagnosticsRef),
1571 Ref.get(actionTimelineRef),
1572 ]);
1573 const sourceSize = sourceImage.getSize();
1574 const image =
1575 sourceSize.width > MAX_SCREENSHOT_WIDTH
1576 ? sourceImage.resize({ width: MAX_SCREENSHOT_WIDTH })
1577 : sourceImage;
1578 const size = image.getSize();
1579 const browserDiagnostics = diagnostics.get(wc.id);
1580 return {
1581 ...page,
1582 accessibilityTree: accessibility,
1583 consoleEntries: [...(browserDiagnostics?.consoleEntries ?? [])],
1584 networkEntries: [...(browserDiagnostics?.networkEntries ?? [])],
1585 actionTimeline: [...(timelines.get(tabId) ?? [])],
1586 screenshot: {
1587 mimeType: "image/png" as const,
1588 data: image.toPNG().toString("base64"),
1589 width: size.width,
1590 height: size.height,
1591 },
1592 };
1593 }),
1615 captureAutomationSnapshot(tabId, wc, send),
15941616 );
15951617 });
15961618
......@@ -1657,66 +1679,72 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
16571679 );
16581680 });
16591681
1682 const performAutomationClick = Effect.fn("PreviewManager.performAutomationClick")(function* (
1683 tabId: string,
1684 input: PreviewAutomationClickInput,
1685 send: SendCommand,
1686 ) {
1687 yield* Effect.all(
1688 [send("Runtime.enable"), send("Input.setIgnoreInputEvents", { ignore: false })],
1689 { concurrency: 2, discard: true },
1690 );
1691 const point = yield* resolveClickPoint(send, input);
1692 const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>(
1693 send,
1694 "({ width: window.innerWidth, height: window.innerHeight })",
1695 true,
1696 );
1697 if (point.x < 0 || point.y < 0 || point.x > viewport.width || point.y > viewport.height) {
1698 return yield* fail(
1699 "automationClick",
1700 automationError(
1701 "PreviewAutomationExecutionError",
1702 `Click coordinates (${point.x}, ${point.y}) are outside the preview viewport.`,
1703 ),
1704 );
1705 }
1706 const moveSequence = yield* nextCounter(pointerSequenceRef);
1707 const moveCreatedAt = yield* currentIso;
1708 yield* emitPointerEvent({
1709 tabId,
1710 phase: "move",
1711 ...point,
1712 sequence: moveSequence,
1713 createdAt: moveCreatedAt,
1714 });
1715 yield* Effect.sleep(AGENT_CURSOR_MOVE_MS);
1716 const clickSequence = yield* nextCounter(pointerSequenceRef);
1717 const clickCreatedAt = yield* currentIso;
1718 yield* emitPointerEvent({
1719 tabId,
1720 phase: "click",
1721 ...point,
1722 sequence: clickSequence,
1723 createdAt: clickCreatedAt,
1724 });
1725 yield* Effect.sleep(AGENT_CURSOR_CLICK_LEAD_MS);
1726 yield* expectAgentInput(tabId, { kind: "pointer", ...point, button: 0 });
1727 yield* send("Input.dispatchMouseEvent", {
1728 type: "mousePressed",
1729 ...point,
1730 button: "left",
1731 clickCount: 1,
1732 });
1733 yield* send("Input.dispatchMouseEvent", {
1734 type: "mouseReleased",
1735 ...point,
1736 button: "left",
1737 clickCount: 1,
1738 });
1739 });
1740
16601741 const automationClick = Effect.fn("PreviewManager.automationClick")(function* (
16611742 tabId: string,
16621743 input: PreviewAutomationClickInput,
16631744 ) {
16641745 const wc = yield* requireWebContents(tabId);
16651746 yield* withControlSession(tabId, wc, "click", (send) =>
1666 Effect.gen(function* () {
1667 yield* Effect.all(
1668 [send("Runtime.enable"), send("Input.setIgnoreInputEvents", { ignore: false })],
1669 { concurrency: 2, discard: true },
1670 );
1671 const point = yield* resolveClickPoint(send, input);
1672 const viewport = yield* evaluateWithDebugger<{ width: number; height: number }>(
1673 send,
1674 "({ width: window.innerWidth, height: window.innerHeight })",
1675 true,
1676 );
1677 if (point.x < 0 || point.y < 0 || point.x > viewport.width || point.y > viewport.height) {
1678 return yield* fail(
1679 "automationClick",
1680 automationError(
1681 "PreviewAutomationExecutionError",
1682 `Click coordinates (${point.x}, ${point.y}) are outside the preview viewport.`,
1683 ),
1684 );
1685 }
1686 const moveSequence = yield* nextCounter(pointerSequenceRef);
1687 const moveCreatedAt = yield* currentIso;
1688 yield* emitPointerEvent({
1689 tabId,
1690 phase: "move",
1691 ...point,
1692 sequence: moveSequence,
1693 createdAt: moveCreatedAt,
1694 });
1695 yield* Effect.sleep(AGENT_CURSOR_MOVE_MS);
1696 const clickSequence = yield* nextCounter(pointerSequenceRef);
1697 const clickCreatedAt = yield* currentIso;
1698 yield* emitPointerEvent({
1699 tabId,
1700 phase: "click",
1701 ...point,
1702 sequence: clickSequence,
1703 createdAt: clickCreatedAt,
1704 });
1705 yield* Effect.sleep(AGENT_CURSOR_CLICK_LEAD_MS);
1706 yield* expectAgentInput(tabId, { kind: "pointer", ...point, button: 0 });
1707 yield* send("Input.dispatchMouseEvent", {
1708 type: "mousePressed",
1709 ...point,
1710 button: "left",
1711 clickCount: 1,
1712 });
1713 yield* send("Input.dispatchMouseEvent", {
1714 type: "mouseReleased",
1715 ...point,
1716 button: "left",
1717 clickCount: 1,
1718 });
1719 }),
1747 performAutomationClick(tabId, input, send),
17201748 );
17211749 });
17221750
......@@ -1769,171 +1797,188 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
17691797 }
17701798 });
17711799
1800 const performAutomationType = Effect.fn("PreviewManager.performAutomationType")(function* (
1801 tabId: string,
1802 input: PreviewAutomationTypeInput,
1803 send: SendCommand,
1804 ) {
1805 yield* send("Runtime.enable");
1806 yield* focusAutomationTarget(send, input);
1807 yield* send("Input.insertText", { text: input.text });
1808 const textJson = yield* encodeJson("automationType.encodeText", input.text);
1809 yield* evaluateWithDebugger(
1810 send,
1811 `(() => {
1812 const element = document.activeElement;
1813 element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${textJson} }));
1814 element?.dispatchEvent(new Event("change", { bubbles: true }));
1815 })()`,
1816 false,
1817 );
1818 });
1819
17721820 const automationType = Effect.fn("PreviewManager.automationType")(function* (
17731821 tabId: string,
17741822 input: PreviewAutomationTypeInput,
17751823 ) {
17761824 const wc = yield* requireWebContents(tabId);
17771825 yield* withControlSession(tabId, wc, "type", (send) =>
1778 Effect.gen(function* () {
1779 yield* send("Runtime.enable");
1780 yield* focusAutomationTarget(send, input);
1781 yield* send("Input.insertText", { text: input.text });
1782 const textJson = yield* encodeJson("automationType.encodeText", input.text);
1783 yield* evaluateWithDebugger(
1784 send,
1785 `(() => {
1786 const element = document.activeElement;
1787 element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${textJson} }));
1788 element?.dispatchEvent(new Event("change", { bubbles: true }));
1789 })()`,
1790 false,
1791 );
1792 }),
1826 performAutomationType(tabId, input, send),
17931827 );
17941828 });
17951829
1830 const performAutomationPress = Effect.fn("PreviewManager.performAutomationPress")(function* (
1831 tabId: string,
1832 input: PreviewAutomationPressInput,
1833 send: SendCommand,
1834 ) {
1835 const modifiers = (input.modifiers ?? []).reduce((value, modifier) => {
1836 switch (modifier) {
1837 case "Alt":
1838 return value | 1;
1839 case "Control":
1840 return value | 2;
1841 case "Meta":
1842 return value | 4;
1843 case "Shift":
1844 return value | 8;
1845 }
1846 }, 0);
1847 const key = input.key;
1848 const text = key.length === 1 ? key : undefined;
1849 const params = {
1850 key,
1851 code: key.length === 1 ? `Key${key.toUpperCase()}` : key,
1852 modifiers,
1853 ...(text ? { text, unmodifiedText: text } : {}),
1854 };
1855 yield* expectAgentInput(tabId, { kind: "key", key, code: params.code });
1856 yield* send("Input.dispatchKeyEvent", { type: "keyDown", ...params });
1857 yield* send("Input.dispatchKeyEvent", { type: "keyUp", ...params });
1858 });
1859
17961860 const automationPress = Effect.fn("PreviewManager.automationPress")(function* (
17971861 tabId: string,
17981862 input: PreviewAutomationPressInput,
17991863 ) {
18001864 const wc = yield* requireWebContents(tabId);
18011865 yield* withControlSession(tabId, wc, "press", (send) =>
1802 Effect.gen(function* () {
1803 const modifiers = (input.modifiers ?? []).reduce((value, modifier) => {
1804 switch (modifier) {
1805 case "Alt":
1806 return value | 1;
1807 case "Control":
1808 return value | 2;
1809 case "Meta":
1810 return value | 4;
1811 case "Shift":
1812 return value | 8;
1813 }
1814 }, 0);
1815 const key = input.key;
1816 const text = key.length === 1 ? key : undefined;
1817 const params = {
1818 key,
1819 code: key.length === 1 ? `Key${key.toUpperCase()}` : key,
1820 modifiers,
1821 ...(text ? { text, unmodifiedText: text } : {}),
1822 };
1823 yield* expectAgentInput(tabId, { kind: "key", key, code: params.code });
1824 yield* send("Input.dispatchKeyEvent", { type: "keyDown", ...params });
1825 yield* send("Input.dispatchKeyEvent", { type: "keyUp", ...params });
1826 }),
1866 performAutomationPress(tabId, input, send),
18271867 );
18281868 });
18291869
1870 const performAutomationScroll = Effect.fn("PreviewManager.performAutomationScroll")(function* (
1871 tabId: string,
1872 input: PreviewAutomationScrollInput,
1873 send: SendCommand,
1874 ) {
1875 yield* send("Runtime.enable");
1876 const locator = automationLocator(input);
1877 if (locator) yield* ensurePlaywrightInjected(send);
1878 const locatorJson = locator
1879 ? yield* encodeJson("automationScroll.encodeLocator", locator)
1880 : null;
1881 const result = yield* evaluateWithDebugger<
1882 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }
1883 >(
1884 send,
1885 `(() => {
1886 try {
1887 const target = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "window"};
1888 if (!target) return { notFound: true };
1889 target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" });
1890 return { ok: true };
1891 } catch (error) {
1892 return { invalidSelector: true, message: String(error) };
1893 }
1894 })()`,
1895 true,
1896 );
1897 if ("invalidSelector" in result) {
1898 return yield* fail(
1899 "automationScroll",
1900 automationError("PreviewAutomationInvalidSelectorError", result.message, {
1901 selector: input.selector ?? "",
1902 }),
1903 );
1904 }
1905 if ("notFound" in result) {
1906 return yield* fail(
1907 "automationScroll",
1908 automationError(
1909 "PreviewAutomationExecutionError",
1910 `No element matches locator ${locator}.`,
1911 ),
1912 );
1913 }
1914 });
1915
18301916 const automationScroll = Effect.fn("PreviewManager.automationScroll")(function* (
18311917 tabId: string,
18321918 input: PreviewAutomationScrollInput,
18331919 ) {
18341920 const wc = yield* requireWebContents(tabId);
18351921 yield* withControlSession(tabId, wc, "scroll", (send) =>
1836 Effect.gen(function* () {
1837 yield* send("Runtime.enable");
1838 const locator = automationLocator(input);
1839 if (locator) yield* ensurePlaywrightInjected(send);
1840 const locatorJson = locator
1841 ? yield* encodeJson("automationScroll.encodeLocator", locator)
1842 : null;
1843 const result = yield* evaluateWithDebugger<
1844 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }
1845 >(
1846 send,
1847 `(() => {
1848 try {
1849 const target = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, true); })()` : "window"};
1850 if (!target) return { notFound: true };
1851 target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" });
1852 return { ok: true };
1853 } catch (error) {
1854 return { invalidSelector: true, message: String(error) };
1855 }
1856 })()`,
1857 true,
1858 );
1859 if ("invalidSelector" in result) {
1860 return yield* fail(
1861 "automationScroll",
1862 automationError("PreviewAutomationInvalidSelectorError", result.message, {
1863 selector: input.selector ?? "",
1864 }),
1865 );
1866 }
1867 if ("notFound" in result) {
1868 return yield* fail(
1869 "automationScroll",
1870 automationError(
1871 "PreviewAutomationExecutionError",
1872 `No element matches locator ${locator}.`,
1873 ),
1874 );
1875 }
1876 }),
1922 performAutomationScroll(tabId, input, send),
18771923 );
18781924 });
18791925
1926 const performAutomationEvaluate = Effect.fn("PreviewManager.performAutomationEvaluate")(
1927 function* (input: PreviewAutomationEvaluateInput, send: SendCommand) {
1928 yield* send("Runtime.enable");
1929 const value = yield* evaluateWithDebugger(
1930 send,
1931 input.expression,
1932 input.returnByValue ?? true,
1933 input.awaitPromise ?? true,
1934 );
1935 const serialized = yield* encodeJson("automationEvaluate.encodeResult", value);
1936 if (Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES) {
1937 return yield* fail(
1938 "automationEvaluate",
1939 automationError(
1940 "PreviewAutomationResultTooLargeError",
1941 `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`,
1942 { maximumBytes: MAX_EVALUATION_BYTES },
1943 ),
1944 );
1945 }
1946 return value;
1947 },
1948 );
1949
18801950 const automationEvaluate = Effect.fn("PreviewManager.automationEvaluate")(function* (
18811951 tabId: string,
18821952 input: PreviewAutomationEvaluateInput,
18831953 ) {
18841954 const wc = yield* requireWebContents(tabId);
18851955 return yield* withControlSession(tabId, wc, "evaluate", (send) =>
1886 Effect.gen(function* () {
1887 yield* send("Runtime.enable");
1888 const value = yield* evaluateWithDebugger(
1889 send,
1890 input.expression,
1891 input.returnByValue ?? true,
1892 input.awaitPromise ?? true,
1893 );
1894 const serialized = yield* encodeJson("automationEvaluate.encodeResult", value);
1895 if (Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES) {
1896 return yield* fail(
1897 "automationEvaluate",
1898 automationError(
1899 "PreviewAutomationResultTooLargeError",
1900 `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`,
1901 { maximumBytes: MAX_EVALUATION_BYTES },
1902 ),
1903 );
1904 }
1905 return value;
1906 }),
1956 performAutomationEvaluate(input, send),
19071957 );
19081958 });
19091959
1910 const automationWaitFor = Effect.fn("PreviewManager.automationWaitFor")(function* (
1911 tabId: string,
1960 const performAutomationWaitFor = Effect.fn("PreviewManager.performAutomationWaitFor")(function* (
19121961 input: PreviewAutomationWaitForInput,
1962 send: SendCommand,
19131963 ) {
1914 const wc = yield* requireWebContents(tabId);
19151964 const timeoutMs = input.timeoutMs ?? 15_000;
1916 yield* withControlSession(tabId, wc, "waitFor", (send) =>
1917 Effect.gen(function* () {
1918 yield* send("Runtime.enable");
1919 const locator = automationLocator(input);
1920 if (locator) yield* ensurePlaywrightInjected(send);
1921 const [locatorJson, textJson, urlIncludesJson] = yield* Effect.all([
1922 locator ? encodeJson("automationWaitFor.encodeLocator", locator) : Effect.succeed(null),
1923 input.text
1924 ? encodeJson("automationWaitFor.encodeText", input.text)
1925 : Effect.succeed(null),
1926 input.urlIncludes
1927 ? encodeJson("automationWaitFor.encodeUrl", input.urlIncludes)
1928 : Effect.succeed(null),
1929 ]);
1930 const deadline = (yield* currentMillis) + timeoutMs;
1931 while ((yield* currentMillis) <= deadline) {
1932 const result = yield* evaluateWithDebugger<
1933 { matched: boolean } | { invalidSelector: true; message: string }
1934 >(
1935 send,
1936 `(() => {
1965 yield* send("Runtime.enable");
1966 const locator = automationLocator(input);
1967 if (locator) yield* ensurePlaywrightInjected(send);
1968 const [locatorJson, textJson, urlIncludesJson] = yield* Effect.all([
1969 locator ? encodeJson("automationWaitFor.encodeLocator", locator) : Effect.succeed(null),
1970 input.text ? encodeJson("automationWaitFor.encodeText", input.text) : Effect.succeed(null),
1971 input.urlIncludes
1972 ? encodeJson("automationWaitFor.encodeUrl", input.urlIncludes)
1973 : Effect.succeed(null),
1974 ]);
1975 const deadline = (yield* currentMillis) + timeoutMs;
1976 while ((yield* currentMillis) <= deadline) {
1977 const result = yield* evaluateWithDebugger<
1978 { matched: boolean } | { invalidSelector: true; message: string }
1979 >(
1980 send,
1981 `(() => {
19371982 try {
19381983 const selectorMatched = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, false) !== null; })()` : "true"};
19391984 const textMatched = ${
......@@ -1947,27 +1992,35 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
19471992 return { invalidSelector: true, message: String(error) };
19481993 }
19491994 })()`,
1950 true,
1951 );
1952 if ("invalidSelector" in result) {
1953 return yield* fail(
1954 "automationWaitFor",
1955 automationError("PreviewAutomationInvalidSelectorError", result.message, {
1956 selector: input.selector ?? "",
1957 }),
1958 );
1959 }
1960 if (result.matched) return;
1961 yield* Effect.sleep(100);
1962 }
1995 true,
1996 );
1997 if ("invalidSelector" in result) {
19631998 return yield* fail(
19641999 "automationWaitFor",
1965 automationError(
1966 "PreviewAutomationTimeoutError",
1967 `Preview condition did not match within ${timeoutMs}ms.`,
1968 ),
2000 automationError("PreviewAutomationInvalidSelectorError", result.message, {
2001 selector: input.selector ?? "",
2002 }),
19692003 );
1970 }),
2004 }
2005 if (result.matched) return;
2006 yield* Effect.sleep(100);
2007 }
2008 return yield* fail(
2009 "automationWaitFor",
2010 automationError(
2011 "PreviewAutomationTimeoutError",
2012 `Preview condition did not match within ${timeoutMs}ms.`,
2013 ),
2014 );
2015 });
2016
2017 const automationWaitFor = Effect.fn("PreviewManager.automationWaitFor")(function* (
2018 tabId: string,
2019 input: PreviewAutomationWaitForInput,
2020 ) {
2021 const wc = yield* requireWebContents(tabId);
2022 yield* withControlSession(tabId, wc, "waitFor", (send) =>
2023 performAutomationWaitFor(input, send),
19712024 );
19722025 });
19732026
apps/server/src/mcp/McpHttpServer.test.ts+1-1
......@@ -158,7 +158,7 @@ it.effect("registers annotated tools and preserves authenticated request context
158158 Effect.provideService(McpSchema.McpServerClient, client),
159159 );
160160 expect(press.isError).toBe(false);
161 expect(press.structuredContent).toBeUndefined();
161 expect(press.structuredContent).toBeNull();
162162 expect(press.content).toEqual([{ type: "text", text: "null" }]);
163163 }),
164164 ).pipe(Effect.provide(TestLayer)),
apps/server/src/mcp/McpHttpServer.ts+80-75
......@@ -13,8 +13,15 @@ import packageJson from "../../package.json" with { type: "json" };
1313import * as McpInvocationContext from "./McpInvocationContext.ts";
1414import * as McpSessionRegistry from "./McpSessionRegistry.ts";
1515import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
16import { PreviewToolkitHandlersLive } from "./toolkits/preview/handlers.ts";
17import { PreviewToolkit } from "./toolkits/preview/tools.ts";
16import {
17 PreviewSnapshotToolkitHandlersLive,
18 PreviewStandardToolkitHandlersLive,
19} from "./toolkits/preview/handlers.ts";
20import {
21 PreviewSnapshotTool,
22 PreviewSnapshotToolkit,
23 PreviewStandardToolkit,
24} from "./toolkits/preview/tools.ts";
1825
1926const unauthorized = HttpServerResponse.jsonUnsafe(
2027 {
......@@ -81,97 +88,95 @@ const McpAuthMiddlewareLive = HttpRouter.middleware<{
8188 provides: McpInvocationContext.McpInvocationContext;
8289}>()(makeMcpAuthMiddleware).layer;
8390
84const registerPreviewToolkit = Effect.fn("McpHttpServer.registerPreviewToolkit")(function* () {
91const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () {
8592 const server = yield* McpServer.McpServer;
86 const built = yield* PreviewToolkit;
87 const handleTool = built.handle as unknown as (
88 name: keyof typeof built.tools,
89 payload: unknown,
90 ) => Effect.Effect<
91 Stream.Stream<{ readonly encodedResult: unknown }, Error>,
92 Error,
93 McpInvocationContext.McpInvocationContext
94 >;
95 for (const tool of Object.values(built.tools)) {
96 yield* server.addTool({
97 tool: new McpSchema.Tool({
98 name: tool.name,
99 description: Tool.getDescription(tool),
100 inputSchema: Tool.getJsonSchema(tool),
101 annotations: {
102 ...Context.getOption(tool.annotations, Tool.Title).pipe(
103 Option.map((title) => ({ title })),
104 Option.getOrUndefined,
105 ),
106 readOnlyHint: Context.get(tool.annotations, Tool.Readonly),
107 destructiveHint: Context.get(tool.annotations, Tool.Destructive),
108 idempotentHint: Context.get(tool.annotations, Tool.Idempotent),
109 openWorldHint: Context.get(tool.annotations, Tool.OpenWorld),
110 },
111 }),
112 annotations: tool.annotations,
113 handle: (payload) =>
114 handleTool(tool.name as keyof typeof built.tools, payload).pipe(
93 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
94 const built = yield* PreviewSnapshotToolkit;
95 const tool = PreviewSnapshotTool;
96 yield* server.addTool({
97 tool: new McpSchema.Tool({
98 name: tool.name,
99 description: Tool.getDescription(tool),
100 inputSchema: Tool.getJsonSchema(tool),
101 annotations: {
102 ...Context.getOption(tool.annotations, Tool.Title).pipe(
103 Option.map((title) => ({ title })),
104 Option.getOrUndefined,
105 ),
106 readOnlyHint: Context.get(tool.annotations, Tool.Readonly),
107 destructiveHint: Context.get(tool.annotations, Tool.Destructive),
108 idempotentHint: Context.get(tool.annotations, Tool.Idempotent),
109 openWorldHint: Context.get(tool.annotations, Tool.OpenWorld),
110 },
111 }),
112 annotations: tool.annotations,
113 handle: (payload) =>
114 Effect.withFiber((fiber) => {
115 const invocation = Context.getUnsafe(
116 fiber.context,
117 McpInvocationContext.McpInvocationContext,
118 );
119 return built.handle("preview_snapshot", payload).pipe(
115120 Stream.unwrap,
116121 Stream.run(Sink.last()),
117122 Effect.flatMap(Effect.fromOption),
123 Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker),
124 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
118125 Effect.matchCause({
119126 onFailure: (cause) =>
120127 new McpSchema.CallToolResult({
121128 isError: true,
122129 content: [{ type: "text", text: Cause.pretty(cause) }],
123130 }),
124 onSuccess: (result) => {
125 if (tool.name === "preview_snapshot") {
126 const snapshot = result.encodedResult as {
127 readonly screenshot: {
128 readonly mimeType: "image/png";
129 readonly data: string;
130 readonly width: number;
131 readonly height: number;
132 };
133 readonly [key: string]: unknown;
131 onSuccess: ({ encodedResult }) => {
132 const snapshot = encodedResult as {
133 readonly screenshot: {
134 readonly mimeType: "image/png";
135 readonly data: string;
136 readonly width: number;
137 readonly height: number;
134138 };
135 const { screenshot, ...page } = snapshot;
136 const metadata = {
137 ...page,
138 screenshot: {
139 mimeType: screenshot.mimeType,
140 width: screenshot.width,
141 height: screenshot.height,
142 },
143 };
144 return new McpSchema.CallToolResult({
145 isError: false,
146 structuredContent: metadata,
147 content: [
148 { type: "text", text: JSON.stringify(metadata) },
149 {
150 type: "image",
151 data: new Uint8Array(Buffer.from(screenshot.data, "base64")),
152 mimeType: screenshot.mimeType,
153 },
154 ],
155 });
156 }
157 const encodedResultText = JSON.stringify(result.encodedResult) ?? "null";
139 readonly [key: string]: unknown;
140 };
141 const { screenshot, ...page } = snapshot;
142 const metadata = {
143 ...page,
144 screenshot: {
145 mimeType: screenshot.mimeType,
146 width: screenshot.width,
147 height: screenshot.height,
148 },
149 };
158150 return new McpSchema.CallToolResult({
159151 isError: false,
160 structuredContent:
161 result.encodedResult !== null && typeof result.encodedResult === "object"
162 ? result.encodedResult
163 : undefined,
164 content: [{ type: "text", text: encodedResultText }],
152 structuredContent: metadata,
153 content: [
154 { type: "text", text: JSON.stringify(metadata) },
155 {
156 type: "image",
157 data: new Uint8Array(Buffer.from(screenshot.data, "base64")),
158 mimeType: screenshot.mimeType,
159 },
160 ],
165161 });
166162 },
167163 }),
168 ) as unknown as Effect.Effect<McpSchema.CallToolResult, never, McpSchema.McpServerClient>,
169 });
170 }
164 );
165 }),
166 });
171167});
172168
173export const PreviewToolkitRegistrationLive = Layer.effectDiscard(registerPreviewToolkit()).pipe(
174 Layer.provide(PreviewToolkitHandlersLive),
169const PreviewStandardToolkitRegistrationLive = McpServer.toolkit(PreviewStandardToolkit).pipe(
170 Layer.provide(PreviewStandardToolkitHandlersLive),
171);
172
173const PreviewSnapshotRegistrationLive = Layer.effectDiscard(registerPreviewSnapshot()).pipe(
174 Layer.provide(PreviewSnapshotToolkitHandlersLive),
175);
176
177export const PreviewToolkitRegistrationLive = Layer.mergeAll(
178 PreviewStandardToolkitRegistrationLive,
179 PreviewSnapshotRegistrationLive,
175180);
176181
177182const McpTransportLive = McpServer.layerHttp({
apps/server/src/mcp/McpSessionRegistry.ts+35-33
......@@ -1,9 +1,10 @@
11import { ProviderInstanceId, ThreadId } from "@t3tools/contracts";
2import * as Clock from "effect/Clock";
23import * as Context from "effect/Context";
34import * as Crypto from "effect/Crypto";
45import * as Effect from "effect/Effect";
56import * as Layer from "effect/Layer";
6import * as Ref from "effect/Ref";
7import * as SynchronizedRef from "effect/SynchronizedRef";
78import { HttpServer } from "effect/unstable/http";
89
910import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
......@@ -59,15 +60,15 @@ const bytesToHex = (bytes: Uint8Array): string =>
5960
6061const tokenFromBytes = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url");
6162
62const make = Effect.fn("McpSessionRegistry.make")(function* (
63const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
6364 options: McpSessionRegistryOptions = {},
6465) {
6566 const crypto = yield* Crypto.Crypto;
6667 const environment = yield* ServerEnvironment;
6768 const environmentId = yield* environment.getEnvironmentId;
6869 const httpServer = yield* HttpServer.HttpServer;
69 const state = yield* Ref.make<RegistryState>({ records: new Map() });
70 const now = options.now ?? Date.now;
70 const state = yield* SynchronizedRef.make<RegistryState>({ records: new Map() });
71 const currentTimeMillis = options.now ? Effect.sync(options.now) : Clock.currentTimeMillis;
7172 const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
7273 const maximumLifetimeMs = options.maximumLifetimeMs ?? DEFAULT_MAXIMUM_LIFETIME_MS;
7374 const endpoint =
......@@ -81,21 +82,18 @@ const make = Effect.fn("McpSessionRegistry.make")(function* (
8182 .pipe(Effect.map(bytesToHex), Effect.orDie);
8283
8384 const pruneExpired = (records: ReadonlyMap<string, CredentialRecord>, timestamp: number) => {
84 let changed = false;
85 const next = new Map<string, CredentialRecord>();
86 for (const [hash, record] of records) {
87 if (timestamp <= record.scope.expiresAt && timestamp - record.lastUsedAt <= idleTimeoutMs) {
88 next.set(hash, record);
89 } else {
90 changed = true;
91 }
92 }
93 return changed ? next : records;
85 const next = new Map(
86 Array.from(records).filter(
87 ([, record]) =>
88 timestamp <= record.scope.expiresAt && timestamp - record.lastUsedAt <= idleTimeoutMs,
89 ),
90 );
91 return next.size === records.size ? records : next;
9492 };
9593
9694 const issue: McpSessionRegistryShape["issue"] = Effect.fn("McpSessionRegistry.issue")(
9795 function* (request) {
98 const issuedAt = now();
96 const issuedAt = yield* currentTimeMillis;
9997 const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie);
10098 const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie);
10199 const tokenHash = yield* hashToken(rawToken);
......@@ -109,7 +107,7 @@ const make = Effect.fn("McpSessionRegistry.make")(function* (
109107 issuedAt,
110108 expiresAt,
111109 };
112 yield* Ref.update(state, ({ records }) => {
110 yield* SynchronizedRef.update(state, ({ records }) => {
113111 const next = new Map(pruneExpired(records, issuedAt));
114112 next.set(tokenHash, { tokenHash, scope, lastUsedAt: issuedAt });
115113 return { records: next };
......@@ -132,23 +130,20 @@ const make = Effect.fn("McpSessionRegistry.make")(function* (
132130 function* (rawToken) {
133131 if (rawToken.length === 0) return undefined;
134132 const tokenHash = yield* hashToken(rawToken);
135 const timestamp = now();
136 let resolved: McpInvocationContext.McpInvocationScope | undefined;
137 yield* Ref.update(state, ({ records }) => {
133 const timestamp = yield* currentTimeMillis;
134 return yield* SynchronizedRef.modify(state, ({ records }) => {
138135 const current = pruneExpired(records, timestamp);
139136 const record = current.get(tokenHash);
140 if (!record) return { records: current };
141 resolved = record.scope;
137 if (!record) return [undefined, { records: current }] as const;
142138 const next = new Map(current);
143139 next.set(tokenHash, { ...record, lastUsedAt: timestamp });
144 return { records: next };
140 return [record.scope, { records: next }] as const;
145141 });
146 return resolved;
147142 },
148143 );
149144
150145 const revokeWhere = (predicate: (record: CredentialRecord) => boolean) =>
151 Ref.update(state, ({ records }) => ({
146 SynchronizedRef.update(state, ({ records }) => ({
152147 records: new Map(Array.from(records).filter(([, record]) => !predicate(record))),
153148 }));
154149
......@@ -163,27 +158,34 @@ const make = Effect.fn("McpSessionRegistry.make")(function* (
163158 revokeThread: Effect.fn("McpSessionRegistry.revokeThread")(function* (threadId) {
164159 yield* revokeWhere((record) => record.scope.threadId === threadId);
165160 }),
166 revokeAll: Ref.set(state, { records: new Map() }),
161 revokeAll: SynchronizedRef.set(state, { records: new Map() }),
167162 });
168163});
169164
170165let activeMcpSessionRegistry: McpSessionRegistryShape | undefined;
171166
172export const layer: Layer.Layer<
173 McpSessionRegistry,
174 never,
175 Crypto.Crypto | ServerEnvironment | HttpServer.HttpServer
176> = Layer.effect(
177 McpSessionRegistry,
178 make().pipe(
167const make = Effect.acquireRelease(
168 makeWithOptions().pipe(
179169 Effect.tap((registry) =>
180170 Effect.sync(() => {
181171 activeMcpSessionRegistry = registry;
182172 }),
183173 ),
184174 ),
175 (registry) =>
176 Effect.sync(() => {
177 if (activeMcpSessionRegistry === registry) {
178 activeMcpSessionRegistry = undefined;
179 }
180 }),
185181);
186182
183export const layer: Layer.Layer<
184 McpSessionRegistry,
185 never,
186 Crypto.Crypto | ServerEnvironment | HttpServer.HttpServer
187> = Layer.effect(McpSessionRegistry, make);
188
187189export const issueActiveMcpCredential = (
188190 request: McpCredentialRequest,
189191): Effect.Effect<McpIssuedCredential | undefined> =>
......@@ -201,5 +203,5 @@ export const revokeAllActiveMcpCredentials = (): Effect.Effect<void> =>
201203
202204/** Exposed for tests. */
203205export const __testing = {
204 make,
206 make: makeWithOptions,
205207};
apps/server/src/mcp/PreviewAutomationBroker.test.ts+3-3
......@@ -23,7 +23,7 @@ const scope = {
2323it.effect("routes a request to the focused owner and correlates its response", () =>
2424 Effect.scoped(
2525 Effect.gen(function* () {
26 const broker = yield* PreviewAutomationBroker.__testing.make();
26 const broker = yield* PreviewAutomationBroker.__testing.make;
2727 const requests = yield* broker.connect("client-1");
2828 yield* Stream.runForEach(requests, (request) =>
2929 broker.respond({
......@@ -56,7 +56,7 @@ it.effect("routes a request to the focused owner and correlates its response", (
5656
5757it.effect("rejects calls when no focused owner exists", () =>
5858 Effect.gen(function* () {
59 const broker = yield* PreviewAutomationBroker.__testing.make();
59 const broker = yield* PreviewAutomationBroker.__testing.make;
6060 const error = yield* broker
6161 .invoke<void>({ scope, operation: "status", input: {} })
6262 .pipe(Effect.flip);
......@@ -67,7 +67,7 @@ it.effect("rejects calls when no focused owner exists", () =>
6767it.effect("routes interactive commands to a hidden durable browser host", () =>
6868 Effect.scoped(
6969 Effect.gen(function* () {
70 const broker = yield* PreviewAutomationBroker.__testing.make();
70 const broker = yield* PreviewAutomationBroker.__testing.make;
7171 const requests = yield* broker.connect("client-hidden");
7272 yield* Stream.runForEach(requests, (request) =>
7373 broker.respond({ requestId: request.requestId, ok: true }),
apps/server/src/mcp/PreviewAutomationBroker.ts+53-49
......@@ -21,8 +21,8 @@ import * as Effect from "effect/Effect";
2121import * as Layer from "effect/Layer";
2222import * as Option from "effect/Option";
2323import * as Queue from "effect/Queue";
24import * as Ref from "effect/Ref";
2524import * as Stream from "effect/Stream";
25import * as SynchronizedRef from "effect/SynchronizedRef";
2626
2727import * as McpInvocationContext from "./McpInvocationContext.ts";
2828
......@@ -71,6 +71,7 @@ interface BrokerState {
7171 readonly clients: ReadonlyMap<string, ClientConnection>;
7272 readonly owners: ReadonlyMap<string, PreviewAutomationOwner>;
7373 readonly pending: ReadonlyMap<string, PendingRequest>;
74 readonly requestSequence: number;
7475}
7576
7677const makeResponseError = (
......@@ -119,33 +120,35 @@ const makeResponseError = (
119120 }
120121};
121122
122const make = Effect.fn("PreviewAutomationBroker.make")(function* () {
123 const state = yield* Ref.make<BrokerState>({
123const make = Effect.gen(function* PreviewAutomationBrokerMake() {
124 const state = yield* SynchronizedRef.make<BrokerState>({
124125 clients: new Map(),
125126 owners: new Map(),
126127 pending: new Map(),
128 requestSequence: 0,
127129 });
128 let requestSequence = 0;
129130
130131 const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* (
131132 clientId: string,
132133 queue: ClientConnection["queue"],
133134 ) {
134 const toFail: PendingRequest[] = [];
135 yield* Ref.update(state, (current) => {
136 if (current.clients.get(clientId)?.queue !== queue) return current;
135 const toFail = yield* SynchronizedRef.modify(state, (current) => {
136 if (current.clients.get(clientId)?.queue !== queue) {
137 return [[] as ReadonlyArray<PendingRequest>, current] as const;
138 }
137139 const clients = new Map(current.clients);
138140 const owners = new Map(current.owners);
139141 const pending = new Map(current.pending);
142 const disconnected: PendingRequest[] = [];
140143 clients.delete(clientId);
141144 owners.delete(clientId);
142145 for (const [requestId, entry] of pending) {
143146 if (entry.clientId === clientId) {
144147 pending.delete(requestId);
145 toFail.push(entry);
148 disconnected.push(entry);
146149 }
147150 }
148 return { clients, owners, pending };
151 return [disconnected, { ...current, clients, owners, pending }] as const;
149152 });
150153 yield* Effect.forEach(
151154 toFail,
......@@ -165,12 +168,10 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () {
165168 "PreviewAutomationBroker.connect",
166169 )(function* (clientId) {
167170 const queue = yield* Queue.unbounded<import("@t3tools/contracts").PreviewAutomationRequest>();
168 let previous: ClientConnection | undefined;
169 yield* Ref.update(state, (current) => {
170 previous = current.clients.get(clientId);
171 const previous = yield* SynchronizedRef.modify(state, (current) => {
171172 const clients = new Map(current.clients);
172173 clients.set(clientId, { clientId, queue });
173 return { ...current, clients };
174 return [current.clients.get(clientId), { ...current, clients }] as const;
174175 });
175176 if (previous) yield* disconnect(clientId, previous.queue);
176177 return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue)));
......@@ -179,7 +180,7 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () {
179180 const reportOwner: PreviewAutomationBrokerShape["reportOwner"] = Effect.fn(
180181 "PreviewAutomationBroker.reportOwner",
181182 )(function* (owner) {
182 yield* Ref.update(state, (current) => {
183 yield* SynchronizedRef.update(state, (current) => {
183184 const owners = new Map(current.owners);
184185 owners.set(owner.clientId, owner);
185186 return { ...current, owners };
......@@ -189,7 +190,7 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () {
189190 const clearOwner: PreviewAutomationBrokerShape["clearOwner"] = Effect.fn(
190191 "PreviewAutomationBroker.clearOwner",
191192 )(function* (clientId) {
192 yield* Ref.update(state, (current) => {
193 yield* SynchronizedRef.update(state, (current) => {
193194 const owners = new Map(current.owners);
194195 owners.delete(clientId);
195196 return { ...current, owners };
......@@ -199,13 +200,12 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () {
199200 const respond: PreviewAutomationBrokerShape["respond"] = Effect.fn(
200201 "PreviewAutomationBroker.respond",
201202 )(function* (response) {
202 let pending: PendingRequest | undefined;
203 yield* Ref.update(state, (current) => {
204 pending = current.pending.get(response.requestId);
205 if (!pending) return current;
203 const pending = yield* SynchronizedRef.modify(state, (current) => {
204 const entry = current.pending.get(response.requestId);
205 if (!entry) return [undefined, current] as const;
206206 const next = new Map(current.pending);
207207 next.delete(response.requestId);
208 return { ...current, pending: next };
208 return [entry, { ...current, pending: next }] as const;
209209 });
210210 if (!pending) return;
211211 if (response.ok) {
......@@ -225,7 +225,7 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () {
225225 const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* <A = unknown>(
226226 input: Parameters<PreviewAutomationBrokerShape["invoke"]>[0],
227227 ): Effect.fn.Return<A, PreviewAutomationError> {
228 const current = yield* Ref.get(state);
228 const current = yield* SynchronizedRef.get(state);
229229 const candidates = Array.from(current.owners.values())
230230 .filter(
231231 (owner) =>
......@@ -256,48 +256,52 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () {
256256 message: "The browser host does not have an active tab.",
257257 });
258258 }
259 const requestId = `preview-${requestSequence++}`;
260259 const timeoutMs = input.timeoutMs ?? 15_000;
261260 const deferred = yield* Deferred.make<unknown, PreviewAutomationError>();
262 yield* Ref.update(state, (next) => {
261 const requestId = yield* SynchronizedRef.modify(state, (next) => {
262 const requestId = `preview-${next.requestSequence}`;
263263 const pending = new Map(next.pending);
264264 pending.set(requestId, { clientId: owner.clientId, deferred });
265 return { ...next, pending };
266 });
267 const offered = yield* Queue.offer(connection.queue, {
268 requestId,
269 threadId: input.scope.threadId,
270 tabId: input.tabId ?? owner.tabId ?? undefined,
271 operation: input.operation,
272 input: input.input,
273 timeoutMs,
265 return [requestId, { ...next, pending, requestSequence: next.requestSequence + 1 }] as const;
274266 });
275 if (!offered) {
276 return yield* new PreviewAutomationUnavailableError({
277 message: "The preview automation client is no longer accepting requests.",
278 });
279 }
280 const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs));
281 yield* Ref.update(state, (next) => {
267 const removePending = SynchronizedRef.update(state, (next) => {
268 if (!next.pending.has(requestId)) return next;
282269 const pending = new Map(next.pending);
283270 pending.delete(requestId);
284271 return { ...next, pending };
285272 });
286 return yield* Option.match(result, {
287 onNone: () =>
288 Effect.fail(
289 new PreviewAutomationTimeoutError({
290 message: `Preview automation timed out after ${timeoutMs}ms.`,
291 }),
292 ),
293 onSome: (value) => Effect.succeed(value as A),
273 const awaitResponse = Effect.fn("PreviewAutomationBroker.awaitResponse")(function* () {
274 const offered = yield* Queue.offer(connection.queue, {
275 requestId,
276 threadId: input.scope.threadId,
277 tabId: input.tabId ?? owner.tabId ?? undefined,
278 operation: input.operation,
279 input: input.input,
280 timeoutMs,
281 });
282 if (!offered) {
283 return yield* new PreviewAutomationUnavailableError({
284 message: "The preview automation client is no longer accepting requests.",
285 });
286 }
287 const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs));
288 return yield* Option.match(result, {
289 onNone: () =>
290 Effect.fail(
291 new PreviewAutomationTimeoutError({
292 message: `Preview automation timed out after ${timeoutMs}ms.`,
293 }),
294 ),
295 onSome: (value) => Effect.succeed(value as A),
296 });
294297 });
298 return yield* awaitResponse().pipe(Effect.ensuring(removePending));
295299 });
296300
297301 return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke });
298});
302}).pipe(Effect.withSpan("PreviewAutomationBroker.make"));
299303
300export const layer = Layer.effect(PreviewAutomationBroker, make());
304export const layer = Layer.effect(PreviewAutomationBroker, make);
301305
302306/** Exposed for tests. */
303307export const __testing = {
apps/server/src/mcp/toolkits/preview/handlers.ts+20-8
......@@ -9,7 +9,7 @@ import type {
99
1010import * as McpInvocationContext from "../../McpInvocationContext.ts";
1111import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
12import { PreviewToolkit } from "./tools.ts";
12import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts";
1313
1414const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>(
1515 operation: PreviewAutomationOperation,
......@@ -30,7 +30,7 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>(
3030 });
3131});
3232
33export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer({
33const handlers = {
3434 preview_status: () => invoke<PreviewAutomationStatus>("status", {}),
3535 preview_open: (input) =>
3636 invoke<PreviewAutomationStatus>("open", {
......@@ -40,12 +40,24 @@ export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer({
4040 }),
4141 preview_navigate: (input) => invoke<PreviewAutomationStatus>("navigate", input, input.timeoutMs),
4242 preview_snapshot: () => invoke<PreviewAutomationSnapshot>("snapshot", {}),
43 preview_click: (input) => invoke<void>("click", input, input.timeoutMs),
44 preview_type: (input) => invoke<void>("type", input, input.timeoutMs),
45 preview_press: (input) => invoke<void>("press", input),
46 preview_scroll: (input) => invoke<void>("scroll", input),
47 preview_evaluate: (input) => invoke<unknown>("evaluate", input),
48 preview_wait_for: (input) => invoke<void>("waitFor", input, input.timeoutMs),
43 preview_click: (input) => invoke<void>("click", input, input.timeoutMs).pipe(Effect.as(null)),
44 preview_type: (input) => invoke<void>("type", input, input.timeoutMs).pipe(Effect.as(null)),
45 preview_press: (input) => invoke<void>("press", input).pipe(Effect.as(null)),
46 preview_scroll: (input) => invoke<void>("scroll", input).pipe(Effect.as(null)),
47 preview_evaluate: (input) =>
48 invoke<unknown>("evaluate", input).pipe(Effect.map((result) => result ?? null)),
49 preview_wait_for: (input) =>
50 invoke<void>("waitFor", input, input.timeoutMs).pipe(Effect.as(null)),
4951 preview_recording_start: () => invoke<PreviewAutomationRecordingStatus>("recordingStart", {}),
5052 preview_recording_stop: () => invoke<PreviewAutomationRecordingArtifact>("recordingStop", {}),
53} satisfies Parameters<typeof PreviewToolkit.toLayer>[0];
54
55const { preview_snapshot, ...standardHandlers } = handlers;
56
57export const PreviewStandardToolkitHandlersLive = PreviewStandardToolkit.toLayer(standardHandlers);
58
59export const PreviewSnapshotToolkitHandlersLive = PreviewSnapshotToolkit.toLayer({
60 preview_snapshot,
5161});
62
63export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer(handlers);
apps/server/src/mcp/toolkits/preview/tools.ts+21
......@@ -84,6 +84,7 @@ export const PreviewClickTool = browserTool(
8484 description:
8585 "Click exactly one page target. Prefer locator with a Playwright selector such as role=button[name='Send']; selector accepts legacy CSS; x and y are viewport CSS pixels and must be supplied together. Call preview_snapshot first when the target is unknown.",
8686 parameters: PreviewAutomationClickInput,
87 success: Schema.Null,
8788 failure: PreviewAutomationError,
8889 dependencies,
8990 }).annotate(Tool.Title, "Click preview page"),
......@@ -94,6 +95,7 @@ export const PreviewTypeTool = browserTool(
9495 description:
9596 "Insert literal text into one input. Prefer locator with a Playwright role/text selector; selector accepts legacy CSS. If neither is supplied, types into the currently focused element. Set clear=true to replace existing text.",
9697 parameters: PreviewAutomationTypeInput,
98 success: Schema.Null,
9799 failure: PreviewAutomationError,
98100 dependencies,
99101 }).annotate(Tool.Title, "Type into preview page"),
......@@ -104,6 +106,7 @@ export const PreviewPressTool = browserTool(
104106 description:
105107 "Press one keyboard key in the active page, for example {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}. This targets the page's current focus.",
106108 parameters: PreviewAutomationPressInput,
109 success: Schema.Null,
107110 failure: PreviewAutomationError,
108111 dependencies,
109112 }).annotate(Tool.Title, "Press key in preview page"),
......@@ -114,6 +117,7 @@ export const PreviewScrollTool = safeBrowserTool(
114117 description:
115118 "Scroll by CSS pixels. Positive deltaY scrolls down and positive deltaX scrolls right. Without locator/selector it scrolls the viewport; otherwise it scrolls that container. At least one delta is required.",
116119 parameters: PreviewAutomationScrollInput,
120 success: Schema.Null,
117121 failure: PreviewAutomationError,
118122 dependencies,
119123 }).annotate(Tool.Title, "Scroll preview page"),
......@@ -135,6 +139,7 @@ export const PreviewWaitForTool = readonlyBrowserTool(
135139 description:
136140 "Wait until all supplied conditions match: a Playwright locator, legacy CSS selector, visible-text substring, and/or URL substring. Provide at least one condition. Defaults to 15 seconds, maximum 60 seconds.",
137141 parameters: PreviewAutomationWaitForInput,
142 success: Schema.Null,
138143 failure: PreviewAutomationError,
139144 dependencies,
140145 }).annotate(Tool.Title, "Wait for preview page condition"),
......@@ -173,3 +178,19 @@ export const PreviewToolkit = Toolkit.make(
173178 PreviewRecordingStartTool,
174179 PreviewRecordingStopTool,
175180);
181
182export const PreviewStandardToolkit = Toolkit.make(
183 PreviewStatusTool,
184 PreviewOpenTool,
185 PreviewNavigateTool,
186 PreviewClickTool,
187 PreviewTypeTool,
188 PreviewPressTool,
189 PreviewScrollTool,
190 PreviewEvaluateTool,
191 PreviewWaitForTool,
192 PreviewRecordingStartTool,
193 PreviewRecordingStopTool,
194);
195
196export const PreviewSnapshotToolkit = Toolkit.make(PreviewSnapshotTool);
apps/server/src/preview/Manager.ts+3-3
......@@ -127,7 +127,7 @@ const buildIdleSnapshot = (input: {
127127 updatedAt: input.updatedAt,
128128});
129129
130const make = Effect.fn("PreviewManager.make")(function* () {
130const make = Effect.gen(function* PreviewManagerMake() {
131131 const stateRef = yield* SynchronizedRef.make<ManagerState>(initialState);
132132 // Unbounded PubSub is fine here — events are tiny and we don't want to
133133 // block publishers if a subscriber is slow. WS clients backpressure on
......@@ -357,6 +357,6 @@ const make = Effect.fn("PreviewManager.make")(function* () {
357357 events,
358358 subscribeEvents: PubSub.subscribe(eventsPubSub),
359359 } satisfies PreviewManagerShape;
360});
360}).pipe(Effect.withSpan("PreviewManager.make"));
361361
362export const layer = Layer.effect(PreviewManager, make());
362export const layer = Layer.effect(PreviewManager, make);
apps/server/src/preview/PortScanner.test.ts+3-5
......@@ -249,20 +249,18 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall
249249 );
250250
251251 it.effect(
252 "retain() drives an immediate broadcast to subscribers",
252 "retain drives an immediate broadcast to subscribers",
253253 Effect.fn("PortScannerTest.retainBroadcastsImmediately")(function* () {
254254 yield* windowsPlatform;
255255 const { port } = yield* commonDevServer;
256256 const received: number[] = [];
257257 const scanner = yield* PortScanner.PortDiscovery;
258 const unsubscribe = yield* scanner.subscribe((servers) =>
258 yield* scanner.subscribe((servers) =>
259259 Effect.sync(() => {
260260 for (const server of servers) received.push(server.port);
261261 }),
262262 );
263 const release = yield* scanner.retain();
264 unsubscribe();
265 release();
263 yield* scanner.retain;
266264 expect(received).toContain(port);
267265 }),
268266 );
apps/server/src/preview/PortScanner.ts+74-55
......@@ -8,13 +8,13 @@
88 * Windows / lsof missing: checks a curated list of common dev ports through
99 * the shared Net service.
1010 *
11 * Polling is reference-counted via `retain()`. A single layer-scoped fiber
11 * Polling is reference-counted via scoped `retain`. A single layer-scoped fiber
1212 * polls forever, but each tick is a no-op when the retain count is zero.
1313 */
1414import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts";
1515import * as Net from "@t3tools/shared/Net";
1616import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview";
17import { Cause, Context, Duration, Effect, Layer, Ref, Schedule } from "effect";
17import { Cause, Context, Duration, Effect, Layer, Ref, Schedule, Scope } from "effect";
1818
1919import { ProcessRunner } from "../processRunner.ts";
2020
......@@ -22,8 +22,8 @@ export interface PortDiscoveryShape {
2222 readonly scan: () => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>;
2323 readonly subscribe: (
2424 listener: (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>,
25 ) => Effect.Effect<() => void>;
26 readonly retain: () => Effect.Effect<() => void>;
25 ) => Effect.Effect<void, never, Scope.Scope>;
26 readonly retain: Effect.Effect<void, never, Scope.Scope>;
2727 readonly registerTerminalProcesses: (input: {
2828 readonly threadId: string;
2929 readonly terminalId: string;
......@@ -51,6 +51,15 @@ type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect
5151
5252interface ScannerState {
5353 readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>;
54 readonly listeners: ReadonlySet<Listener>;
55 readonly terminalProcesses: ReadonlyMap<
56 string,
57 {
58 readonly owner: TerminalProcessOwner;
59 readonly processIds: ReadonlySet<number>;
60 }
61 >;
62 readonly retainCount: number;
5463}
5564
5665interface TerminalProcessOwner {
......@@ -170,24 +179,15 @@ const serversEqual = (
170179 return true;
171180};
172181
173const make = Effect.fn("PortDiscovery.make")(function* () {
182const make = Effect.gen(function* PortDiscoveryMake() {
174183 const net = yield* Net.NetService;
175184 const processRunner = yield* ProcessRunner;
176185 const stateRef = yield* Ref.make<ScannerState>({
177186 lastSnapshot: [],
187 listeners: new Set(),
188 terminalProcesses: new Map(),
189 retainCount: 0,
178190 });
179 const listeners = new Set<Listener>();
180 const terminalProcesses = new Map<
181 string,
182 {
183 readonly owner: TerminalProcessOwner;
184 readonly processIds: ReadonlySet<number>;
185 }
186 >();
187 // Plain integer because the release callback returned by `retain()` runs
188 // outside any Effect context (the WS subscriber's release path) and must
189 // be a synchronous side-effect-only function.
190 let retainCount = 0;
191191
192192 const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () {
193193 const results = yield* Effect.forEach(
......@@ -214,8 +214,9 @@ const make = Effect.fn("PortDiscovery.make")(function* () {
214214 });
215215
216216 const scanOnce = Effect.fn("PortDiscovery.scan")(function* () {
217 const state = yield* Ref.get(stateRef);
217218 const terminalByProcessId = new Map<number, TerminalProcessOwner>();
218 for (const registration of terminalProcesses.values()) {
219 for (const registration of state.terminalProcesses.values()) {
219220 for (const processId of registration.processIds) {
220221 terminalByProcessId.set(processId, registration.owner);
221222 }
......@@ -254,17 +255,23 @@ const make = Effect.fn("PortDiscovery.make")(function* () {
254255 return yield* probeCommonPorts();
255256 });
256257
257 const broadcast = (servers: ReadonlyArray<DiscoveredLocalServer>): Effect.Effect<void> =>
258 Effect.forEach(Array.from(listeners), (listener) => listener(servers), { discard: true });
258 const broadcast = Effect.fn("PortDiscovery.broadcast")(function* (
259 servers: ReadonlyArray<DiscoveredLocalServer>,
260 ) {
261 const listeners = (yield* Ref.get(stateRef)).listeners;
262 yield* Effect.forEach(listeners, (listener) => listener(servers), { discard: true });
263 });
259264
260265 const pollTick = Effect.fn("PortDiscovery.pollTick")(
261266 function* () {
262 if (retainCount <= 0) return;
267 if ((yield* Ref.get(stateRef)).retainCount <= 0) return;
263268 const next = yield* scanOnce();
264 const state = yield* Ref.get(stateRef);
265 if (serversEqual(state.lastSnapshot, next)) return;
266 yield* Ref.update(stateRef, (s) => ({ ...s, lastSnapshot: next }));
267 yield* broadcast(next);
269 const changed = yield* Ref.modify(stateRef, (state) =>
270 serversEqual(state.lastSnapshot, next)
271 ? [false, state]
272 : [true, { ...state, lastSnapshot: next }],
273 );
274 if (changed) yield* broadcast(next);
268275 },
269276 Effect.catchCause((cause: Cause.Cause<never>) =>
270277 Effect.logWarning("preview port scan failed", Cause.pretty(cause)),
......@@ -275,58 +282,70 @@ const make = Effect.fn("PortDiscovery.make")(function* () {
275282 // currently retained, so the cost is one Ref.get every POLL_INTERVAL.
276283 yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL))));
277284
278 const retain: PortDiscoveryShape["retain"] = Effect.fn("PortDiscovery.retain")(function* () {
279 const wasIdle = retainCount === 0;
280 retainCount += 1;
285 const acquireRetention = Effect.fn("PortDiscovery.retain")(function* () {
286 const wasIdle = yield* Ref.modify(stateRef, (state) => [
287 state.retainCount === 0,
288 { ...state, retainCount: state.retainCount + 1 },
289 ]);
281290 if (wasIdle) {
282291 // Run an immediate scan + broadcast so the new retainer doesn't have
283292 // to wait up to POLL_INTERVAL for the first emission.
284293 yield* pollTick();
285294 }
286 let released = false;
287 return () => {
288 if (released) return;
289 released = true;
290 retainCount = Math.max(0, retainCount - 1);
291 };
292295 });
293296
297 const retain: PortDiscoveryShape["retain"] = Effect.acquireRelease(acquireRetention(), () =>
298 Ref.update(stateRef, (state) => ({
299 ...state,
300 retainCount: Math.max(0, state.retainCount - 1),
301 })),
302 );
303
294304 const subscribe: PortDiscoveryShape["subscribe"] = Effect.fn("PortDiscovery.subscribe")(
295 function* (listener) {
296 return yield* Effect.sync(() => {
297 listeners.add(listener);
298 return () => {
299 listeners.delete(listener);
300 };
301 });
302 },
305 (listener) =>
306 Effect.acquireRelease(
307 Ref.update(stateRef, (state) => ({
308 ...state,
309 listeners: new Set([...state.listeners, listener]),
310 })),
311 () =>
312 Ref.update(stateRef, (state) => {
313 const listeners = new Set(state.listeners);
314 listeners.delete(listener);
315 return { ...state, listeners };
316 }),
317 ),
303318 );
304319
305320 const registerTerminalProcesses: PortDiscoveryShape["registerTerminalProcesses"] = Effect.fn(
306321 "PortDiscovery.registerTerminalProcesses",
307322 )(function* (input) {
308 yield* Effect.sync(() => {
309 const owner = {
310 threadId: ThreadId.make(input.threadId),
311 terminalId: input.terminalId,
312 };
313 const processIds = new Set(
314 input.processIds.filter((processId) => Number.isInteger(processId) && processId > 0),
315 );
323 const owner = {
324 threadId: ThreadId.make(input.threadId),
325 terminalId: input.terminalId,
326 };
327 const processIds = new Set(
328 input.processIds.filter((processId) => Number.isInteger(processId) && processId > 0),
329 );
330 yield* Ref.update(stateRef, (state) => {
331 const terminalProcesses = new Map(state.terminalProcesses);
316332 const key = terminalOwnerKey(owner);
317333 if (processIds.size === 0) {
318334 terminalProcesses.delete(key);
319 return;
335 } else {
336 terminalProcesses.set(key, { owner, processIds });
320337 }
321 terminalProcesses.set(key, { owner, processIds });
338 return { ...state, terminalProcesses };
322339 });
323340 });
324341
325342 const unregisterTerminal: PortDiscoveryShape["unregisterTerminal"] = Effect.fn(
326343 "PortDiscovery.unregisterTerminal",
327344 )(function* (input) {
328 yield* Effect.sync(() => {
345 yield* Ref.update(stateRef, (state) => {
346 const terminalProcesses = new Map(state.terminalProcesses);
329347 terminalProcesses.delete(terminalOwnerKey(input));
348 return { ...state, terminalProcesses };
330349 });
331350 });
332351
......@@ -337,9 +356,9 @@ const make = Effect.fn("PortDiscovery.make")(function* () {
337356 registerTerminalProcesses,
338357 unregisterTerminal,
339358 } satisfies PortDiscoveryShape;
340});
359}).pipe(Effect.withSpan("PortDiscovery.make"));
341360
342export const layer = Layer.effect(PortDiscovery, make());
361export const layer = Layer.effect(PortDiscovery, make);
343362
344363/** Exposed for tests. */
345364export const __testing = {
apps/server/src/server.test.ts+2-2
......@@ -683,8 +683,8 @@ const buildAppUnderTest = (options?: {
683683 }),
684684 Layer.mock(PortScanner.PortDiscovery)({
685685 scan: () => Effect.succeed([]),
686 subscribe: () => Effect.succeed(() => {}),
687 retain: () => Effect.succeed(() => {}),
686 subscribe: () => Effect.void,
687 retain: Effect.void,
688688 registerTerminalProcesses: () => Effect.void,
689689 unregisterTerminal: () => Effect.void,
690690 }),
apps/server/src/ws.ts+14-22
......@@ -1425,29 +1425,21 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
14251425 observeRpcStream(
14261426 WS_METHODS.subscribeDiscoveredLocalServers,
14271427 Stream.callback<DiscoveredLocalServerList>((queue) =>
1428 Effect.acquireRelease(
1429 Effect.gen(function* () {
1430 const release = yield* portDiscovery.retain();
1431 const initial = yield* portDiscovery.scan();
1432 const initialScannedAt = DateTime.formatIso(yield* DateTime.now);
1433 yield* Queue.offer(queue, {
1434 servers: initial,
1435 scannedAt: initialScannedAt,
1436 });
1437 const unsubscribe = yield* portDiscovery.subscribe((servers) =>
1438 Effect.gen(function* () {
1439 const scannedAt = DateTime.formatIso(yield* DateTime.now);
1440 yield* Queue.offer(queue, { servers, scannedAt });
1441 }),
1442 );
1443 return { unsubscribe, release };
1444 }),
1445 ({ unsubscribe, release }) =>
1446 Effect.sync(() => {
1447 unsubscribe();
1448 release();
1428 Effect.gen(function* () {
1429 yield* portDiscovery.retain;
1430 const initial = yield* portDiscovery.scan();
1431 const initialScannedAt = DateTime.formatIso(yield* DateTime.now);
1432 yield* Queue.offer(queue, {
1433 servers: initial,
1434 scannedAt: initialScannedAt,
1435 });
1436 yield* portDiscovery.subscribe((servers) =>
1437 Effect.gen(function* () {
1438 const scannedAt = DateTime.formatIso(yield* DateTime.now);
1439 yield* Queue.offer(queue, { servers, scannedAt });
14491440 }),
1450 ),
1441 );
1442 }),
14511443 ),
14521444 { "rpc.aggregate": "preview" },
14531445 ),