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";...@@ -48,7 +48,7 @@ import * as Path from "effect/Path";
48import * as Ref from "effect/Ref";48import * as Ref from "effect/Ref";
49import * as Schema from "effect/Schema";49import * as Schema from "effect/Schema";
50import * as Semaphore from "effect/Semaphore";50import * as Semaphore from "effect/Semaphore";
51import type * as Scope from "effect/Scope";51import * as Scope from "effect/Scope";
52import * as SynchronizedRef from "effect/SynchronizedRef";52import * as SynchronizedRef from "effect/SynchronizedRef";
5353
54import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";54import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
...@@ -247,10 +247,7 @@ type PreviewInputSignal =...@@ -247,10 +247,7 @@ type PreviewInputSignal =
247 | { readonly kind: "key"; readonly key: string; readonly code: string };247 | { readonly kind: "key"; readonly key: string; readonly code: string };
248248
249interface ManagedListeners {249interface ManagedListeners {
250 navigate: () => void;250 readonly scope: Scope.Closeable;
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;
254}251}
255252
256interface PickSession {253interface PickSession {
...@@ -260,6 +257,7 @@ interface PickSession {...@@ -260,6 +257,7 @@ interface PickSession {
260interface BrowserControlSession {257interface BrowserControlSession {
261 readonly webContentsId: number;258 readonly webContentsId: number;
262 readonly semaphore: Semaphore.Semaphore;259 readonly semaphore: Semaphore.Semaphore;
260 readonly scope: Scope.Closeable;
263 readonly onMessage: (261 readonly onMessage: (
264 event: Electron.Event,262 event: Electron.Event,
265 method: string,263 method: string,
...@@ -339,6 +337,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -339,6 +337,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
339) {337) {
340 const fileSystem = yield* FileSystem.FileSystem;338 const fileSystem = yield* FileSystem.FileSystem;
341 const path = yield* Path.Path;339 const path = yield* Path.Path;
340 const parentScope = yield* Scope.Scope;
342 const context = yield* Effect.context<never>();341 const context = yield* Effect.context<never>();
343 const runFork = Effect.runForkWith(context);342 const runFork = Effect.runForkWith(context);
344 const resolvedArtifactDirectory = path.resolve(artifactDirectory);343 const resolvedArtifactDirectory = path.resolve(artifactDirectory);
...@@ -622,16 +621,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -622,16 +621,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
622 copy.delete(webContentsId);621 copy.delete(webContentsId);
623 }),622 }),
624 ]);623 ]);
624 if (control) {
625 yield* Scope.close(control.scope, Exit.void).pipe(Effect.ignore);
626 return;
627 }
625 yield* Ref.update(diagnosticsRef, (diagnostics) =>628 yield* Ref.update(diagnosticsRef, (diagnostics) =>
626 replaceMap(diagnostics, (copy) => {629 replaceMap(diagnostics, (copy) => {
627 copy.delete(webContentsId);630 copy.delete(webContentsId);
628 }),631 }),
629 );632 );
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);
635 });633 });
636634
637 const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* (635 const ensureControlSession = Effect.fn("PreviewManager.ensureControlSession")(function* (
...@@ -662,87 +660,103 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -662,87 +660,103 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
662 ),660 ),
663 );661 );
664 }662 }
665 return Effect.gen(function* () {663 const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () {
666 const semaphore = yield* Semaphore.make(1);664 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 });
667 const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => {701 const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => {
668 runFork(702 runFork(handleDebuggerMessage(method, params));
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 );
704 };703 };
705 const control: BrowserControlSession = { webContentsId: wc.id, semaphore, onMessage };704 yield* Scope.addFinalizer(
706 yield* Ref.update(diagnosticsRef, (diagnostics) =>705 scope,
707 replaceMap(diagnostics, (copy) => {706 Effect.all(
708 copy.set(wc.id, {707 [
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([
727 Ref.update(diagnosticsRef, (diagnostics) =>708 Ref.update(diagnosticsRef, (diagnostics) =>
728 replaceMap(diagnostics, (copy) => {709 replaceMap(diagnostics, (copy) => {
729 copy.delete(wc.id);710 copy.delete(wc.id);
730 }),711 }),
731 ),712 ),
732 attempt("detachFailedDebugger", () => {713 attempt("detachControlSession", () => {
733 wc.debugger.off("message", onMessage);714 wc.debugger.off("message", onMessage);
734 if (wc.debugger.isAttached()) wc.debugger.detach();715 if (wc.debugger.isAttached()) wc.debugger.detach();
735 }).pipe(Effect.ignore),716 }).pipe(Effect.ignore),
736 ]).pipe(Effect.asVoid),717 ],
718 { discard: true },
737 ),719 ),
738 );720 );
739 return [721 const control: BrowserControlSession = {
740 control,722 webContentsId: wc.id,
741 replaceMap(sessions, (copy) => {723 semaphore,
742 copy.set(wc.id, control);724 scope,
743 }),725 onMessage,
744 ] as const;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 );
745 });758 });
759 return createControlSession();
746 });760 });
747 });761 });
748762
...@@ -906,25 +920,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -906,25 +920,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
906 const detachListeners = Effect.fn("PreviewManager.detachListeners")(function* (920 const detachListeners = Effect.fn("PreviewManager.detachListeners")(function* (
907 webContentsId: number,921 webContentsId: number,
908 ) {922 ) {
909 const handlers = yield* Ref.modify(attachedRef, (attached) => [923 const managed = yield* Ref.modify(attachedRef, (attached) => [
910 attached.get(webContentsId),924 attached.get(webContentsId),
911 replaceMap(attached, (copy) => {925 replaceMap(attached, (copy) => {
912 copy.delete(webContentsId);926 copy.delete(webContentsId);
913 }),927 }),
914 ]);928 ]);
915 if (!handlers) return;929 if (managed) yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore);
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);
928 });930 });
929931
930 const isAppShortcut = (input: Electron.Input): boolean =>932 const isAppShortcut = (input: Electron.Input): boolean =>
...@@ -987,17 +989,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -987,17 +989,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
987 tabId: string,989 tabId: string,
988 wc: Electron.WebContents,990 wc: Electron.WebContents,
989 ) {991 ) {
990 const sync = () =>992 const scope = yield* Scope.fork(parentScope, "sequential");
991 runFork(993 const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* () {
992 Effect.gen(function* () {994 if (wc.isDestroyed()) return;
993 if (wc.isDestroyed()) return;995 yield* update(tabId, {
994 yield* update(tabId, {996 navStatus: computeNavStatus(wc),
995 navStatus: computeNavStatus(wc),997 canGoBack: wc.navigationHistory.canGoBack(),
996 canGoBack: wc.navigationHistory.canGoBack(),998 canGoForward: wc.navigationHistory.canGoForward(),
997 canGoForward: wc.navigationHistory.canGoForward(),999 });
998 });1000 });
999 }),1001 const sync = () => runFork(syncState());
1000 );
1001 const failed = (_event: Event, code: number, description: string): void => {1002 const failed = (_event: Event, code: number, description: string): void => {
1002 if (code === -3) return;1003 if (code === -3) return;
1003 runFork(1004 runFork(
...@@ -1012,73 +1013,85 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1012,73 +1013,85 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1012 }),1013 }),
1013 );1014 );
1014 };1015 };
1015 const humanInput = (_event: unknown, rawSignal?: unknown): void => {1016 const handleHumanInput = Effect.fn("PreviewManager.handleHumanInput")(function* (
1016 runFork(1017 rawSignal?: unknown,
1017 Effect.gen(function* () {1018 ) {
1018 if (1019 if (isPreviewInputSignal(rawSignal) && (yield* consumeExpectedAgentInput(tabId, rawSignal))) {
1019 isPreviewInputSignal(rawSignal) &&1020 return;
1020 (yield* consumeExpectedAgentInput(tabId, rawSignal))1021 }
1021 ) {1022 yield* Ref.update(controlEpochRef, (epochs) =>
1022 return;1023 replaceMap(epochs, (copy) => {
1023 }1024 copy.set(tabId, (epochs.get(tabId) ?? 0) + 1);
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 }
1035 }),1025 }),
1036 );1026 );
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));
1037 };1036 };
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 });
1038 const beforeInput = (event: Electron.Event, input: Electron.Input): void => {1057 const beforeInput = (event: Electron.Event, input: Electron.Input): void => {
1039 runFork(1058 runFork(forwardShortcut(event, input));
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 );
1062 };1059 };
1063 yield* attempt("attachListeners", () => {1060 yield* Scope.addFinalizer(
1064 wc.on("did-navigate", sync);1061 scope,
1065 wc.on("did-navigate-in-page", sync);1062 attempt("detachListeners", () => {
1066 wc.on("page-title-updated", sync);1063 wc.off("did-navigate", sync);
1067 wc.on("did-start-loading", sync);1064 wc.off("did-navigate-in-page", sync);
1068 wc.on("did-stop-loading", sync);1065 wc.off("page-title-updated", sync);
1069 wc.on("did-fail-load", failed as never);1066 wc.off("did-start-loading", sync);
1070 wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);1067 wc.off("did-stop-loading", sync);
1071 wc.setWindowOpenHandler(({ url }) => {1068 wc.off("did-fail-load", failed as never);
1072 runFork(attemptPromise("openPreviewWindow", () => wc.loadURL(url)).pipe(Effect.ignore));1069 wc.off("before-input-event", beforeInput);
1073 return { action: "deny" };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);
1074 });1087 });
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 );
1076 });1093 });
1077 yield* Ref.update(attachedRef, (attached) =>1094 yield* install().pipe(Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)));
1078 replaceMap(attached, (copy) => {
1079 copy.set(wc.id, { navigate: sync, failed, humanInput, beforeInput });
1080 }),
1081 );
1082 });1095 });
10831096
1084 const setMainWindow = Effect.fn("PreviewManager.setMainWindow")(function* (1097 const setMainWindow = Effect.fn("PreviewManager.setMainWindow")(function* (
...@@ -1271,7 +1284,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1271,7 +1284,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1271 const annotationTheme = yield* Ref.get(annotationThemeRef);1284 const annotationTheme = yield* Ref.get(annotationThemeRef);
1272 return yield* Effect.callback<PreviewAnnotationPayload | null, PreviewManagerError>(1285 return yield* Effect.callback<PreviewAnnotationPayload | null, PreviewManagerError>(
1273 (resume) => {1286 (resume) => {
1274 const cleanup = Effect.gen(function* () {1287 const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () {
1275 yield* attempt("pickElement.cleanup", () => {1288 yield* attempt("pickElement.cleanup", () => {
1276 wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage);1289 wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage);
1277 wc.off("destroyed", onDestroyed);1290 wc.off("destroyed", onDestroyed);
...@@ -1283,18 +1296,19 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1283,18 +1296,19 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1283 }),1296 }),
1284 );1297 );
1285 });1298 });
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 });
1286 const settle = (payload: PreviewAnnotationPayload | null) => {1307 const settle = (payload: PreviewAnnotationPayload | null) => {
1287 runFork(1308 runFork(settlePick(payload));
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 );
1295 };1309 };
1296 const cancel = Effect.gen(function* () {1310 const cancelPickSession = Effect.fn("PreviewManager.cancelPickSession")(function* () {
1297 yield* cleanup;1311 yield* cleanup();
1298 const tabs = yield* SynchronizedRef.get(tabsRef);1312 const tabs = yield* SynchronizedRef.get(tabsRef);
1299 const activeTab = tabs.get(tabId);1313 const activeTab = tabs.get(tabId);
1300 if (activeTab?.webContentsId != null) {1314 if (activeTab?.webContentsId != null) {
...@@ -1307,6 +1321,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1307,6 +1321,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1307 }1321 }
1308 resume(Effect.succeed(null));1322 resume(Effect.succeed(null));
1309 });1323 });
1324 const cancel = cancelPickSession();
1310 const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => {1325 const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => {
1311 const payload = args[0];1326 const payload = args[0];
1312 if (!isPreviewAnnotationPayload(payload)) {1327 if (!isPreviewAnnotationPayload(payload)) {
...@@ -1330,24 +1345,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1330,24 +1345,25 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1330 };1345 };
1331 const onDestroyed = () => settle(null);1346 const onDestroyed = () => settle(null);
1332 const onNavigated = () => settle(null);1347 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 });
1333 runFork(1362 runFork(
1334 Effect.gen(function* () {1363 registerPickElement().pipe(
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(
1348 Effect.catch((error: PreviewManagerError) => {1364 Effect.catch((error: PreviewManagerError) => {
1349 resume(Effect.fail(error));1365 resume(Effect.fail(error));
1350 return cleanup;1366 return cleanup();
1351 }),1367 }),
1352 ),1368 ),
1353 );1369 );
...@@ -1401,6 +1417,19 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1401,6 +1417,19 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1401 };1417 };
1402 });1418 });
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
1404 const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) {1433 const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) {
1405 const recordingTabId = yield* Ref.get(recordingTabIdRef);1434 const recordingTabId = yield* Ref.get(recordingTabIdRef);
1406 if (Option.isSome(recordingTabId) && recordingTabId.value !== tabId) {1435 if (Option.isSome(recordingTabId) && recordingTabId.value !== tabId) {
...@@ -1410,18 +1439,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1410,18 +1439,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1410 );1439 );
1411 }1440 }
1412 const wc = yield* requireWebContents(tabId);1441 const wc = yield* requireWebContents(tabId);
1413 yield* withControlSession(tabId, wc, "recording.start", (send) =>1442 yield* withControlSession(tabId, wc, "recording.start", startScreencast);
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 );
1425 yield* Ref.set(recordingTabIdRef, Option.some(tabId));1443 yield* Ref.set(recordingTabIdRef, Option.some(tabId));
1426 });1444 });
14271445
...@@ -1493,104 +1511,108 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1493,104 +1511,108 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1493 };1511 };
1494 });1512 });
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
1496 const automationSnapshot = Effect.fn("PreviewManager.automationSnapshot")(function* (1610 const automationSnapshot = Effect.fn("PreviewManager.automationSnapshot")(function* (
1497 tabId: string,1611 tabId: string,
1498 ) {1612 ) {
1499 const wc = yield* requireWebContents(tabId);1613 const wc = yield* requireWebContents(tabId);
1500 return yield* withControlSession(tabId, wc, "snapshot", (send) =>1614 return yield* withControlSession(tabId, wc, "snapshot", (send) =>
1501 Effect.gen(function* () {1615 captureAutomationSnapshot(tabId, wc, send),
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 }),
1594 );1616 );
1595 });1617 });
15961618
...@@ -1657,66 +1679,72 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1657,66 +1679,72 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1657 );1679 );
1658 });1680 });
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
1660 const automationClick = Effect.fn("PreviewManager.automationClick")(function* (1741 const automationClick = Effect.fn("PreviewManager.automationClick")(function* (
1661 tabId: string,1742 tabId: string,
1662 input: PreviewAutomationClickInput,1743 input: PreviewAutomationClickInput,
1663 ) {1744 ) {
1664 const wc = yield* requireWebContents(tabId);1745 const wc = yield* requireWebContents(tabId);
1665 yield* withControlSession(tabId, wc, "click", (send) =>1746 yield* withControlSession(tabId, wc, "click", (send) =>
1666 Effect.gen(function* () {1747 performAutomationClick(tabId, input, send),
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 }),
1720 );1748 );
1721 });1749 });
17221750
...@@ -1769,171 +1797,188 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1769,171 +1797,188 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1769 }1797 }
1770 });1798 });
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
1772 const automationType = Effect.fn("PreviewManager.automationType")(function* (1820 const automationType = Effect.fn("PreviewManager.automationType")(function* (
1773 tabId: string,1821 tabId: string,
1774 input: PreviewAutomationTypeInput,1822 input: PreviewAutomationTypeInput,
1775 ) {1823 ) {
1776 const wc = yield* requireWebContents(tabId);1824 const wc = yield* requireWebContents(tabId);
1777 yield* withControlSession(tabId, wc, "type", (send) =>1825 yield* withControlSession(tabId, wc, "type", (send) =>
1778 Effect.gen(function* () {1826 performAutomationType(tabId, input, send),
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 }),
1793 );1827 );
1794 });1828 });
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
1796 const automationPress = Effect.fn("PreviewManager.automationPress")(function* (1860 const automationPress = Effect.fn("PreviewManager.automationPress")(function* (
1797 tabId: string,1861 tabId: string,
1798 input: PreviewAutomationPressInput,1862 input: PreviewAutomationPressInput,
1799 ) {1863 ) {
1800 const wc = yield* requireWebContents(tabId);1864 const wc = yield* requireWebContents(tabId);
1801 yield* withControlSession(tabId, wc, "press", (send) =>1865 yield* withControlSession(tabId, wc, "press", (send) =>
1802 Effect.gen(function* () {1866 performAutomationPress(tabId, input, send),
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 }),
1827 );1867 );
1828 });1868 });
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
1830 const automationScroll = Effect.fn("PreviewManager.automationScroll")(function* (1916 const automationScroll = Effect.fn("PreviewManager.automationScroll")(function* (
1831 tabId: string,1917 tabId: string,
1832 input: PreviewAutomationScrollInput,1918 input: PreviewAutomationScrollInput,
1833 ) {1919 ) {
1834 const wc = yield* requireWebContents(tabId);1920 const wc = yield* requireWebContents(tabId);
1835 yield* withControlSession(tabId, wc, "scroll", (send) =>1921 yield* withControlSession(tabId, wc, "scroll", (send) =>
1836 Effect.gen(function* () {1922 performAutomationScroll(tabId, input, send),
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 }),
1877 );1923 );
1878 });1924 });
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
1880 const automationEvaluate = Effect.fn("PreviewManager.automationEvaluate")(function* (1950 const automationEvaluate = Effect.fn("PreviewManager.automationEvaluate")(function* (
1881 tabId: string,1951 tabId: string,
1882 input: PreviewAutomationEvaluateInput,1952 input: PreviewAutomationEvaluateInput,
1883 ) {1953 ) {
1884 const wc = yield* requireWebContents(tabId);1954 const wc = yield* requireWebContents(tabId);
1885 return yield* withControlSession(tabId, wc, "evaluate", (send) =>1955 return yield* withControlSession(tabId, wc, "evaluate", (send) =>
1886 Effect.gen(function* () {1956 performAutomationEvaluate(input, send),
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 }),
1907 );1957 );
1908 });1958 });
19091959
1910 const automationWaitFor = Effect.fn("PreviewManager.automationWaitFor")(function* (1960 const performAutomationWaitFor = Effect.fn("PreviewManager.performAutomationWaitFor")(function* (
1911 tabId: string,
1912 input: PreviewAutomationWaitForInput,1961 input: PreviewAutomationWaitForInput,
1962 send: SendCommand,
1913 ) {1963 ) {
1914 const wc = yield* requireWebContents(tabId);
1915 const timeoutMs = input.timeoutMs ?? 15_000;1964 const timeoutMs = input.timeoutMs ?? 15_000;
1916 yield* withControlSession(tabId, wc, "waitFor", (send) =>1965 yield* send("Runtime.enable");
1917 Effect.gen(function* () {1966 const locator = automationLocator(input);
1918 yield* send("Runtime.enable");1967 if (locator) yield* ensurePlaywrightInjected(send);
1919 const locator = automationLocator(input);1968 const [locatorJson, textJson, urlIncludesJson] = yield* Effect.all([
1920 if (locator) yield* ensurePlaywrightInjected(send);1969 locator ? encodeJson("automationWaitFor.encodeLocator", locator) : Effect.succeed(null),
1921 const [locatorJson, textJson, urlIncludesJson] = yield* Effect.all([1970 input.text ? encodeJson("automationWaitFor.encodeText", input.text) : Effect.succeed(null),
1922 locator ? encodeJson("automationWaitFor.encodeLocator", locator) : Effect.succeed(null),1971 input.urlIncludes
1923 input.text1972 ? encodeJson("automationWaitFor.encodeUrl", input.urlIncludes)
1924 ? encodeJson("automationWaitFor.encodeText", input.text)1973 : Effect.succeed(null),
1925 : Effect.succeed(null),1974 ]);
1926 input.urlIncludes1975 const deadline = (yield* currentMillis) + timeoutMs;
1927 ? encodeJson("automationWaitFor.encodeUrl", input.urlIncludes)1976 while ((yield* currentMillis) <= deadline) {
1928 : Effect.succeed(null),1977 const result = yield* evaluateWithDebugger<
1929 ]);1978 { matched: boolean } | { invalidSelector: true; message: string }
1930 const deadline = (yield* currentMillis) + timeoutMs;1979 >(
1931 while ((yield* currentMillis) <= deadline) {1980 send,
1932 const result = yield* evaluateWithDebugger<1981 `(() => {
1933 { matched: boolean } | { invalidSelector: true; message: string }
1934 >(
1935 send,
1936 `(() => {
1937 try {1982 try {
1938 const selectorMatched = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, false) !== null; })()` : "true"};1983 const selectorMatched = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, false) !== null; })()` : "true"};
1939 const textMatched = ${1984 const textMatched = ${
...@@ -1947,27 +1992,35 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1947,27 +1992,35 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1947 return { invalidSelector: true, message: String(error) };1992 return { invalidSelector: true, message: String(error) };
1948 }1993 }
1949 })()`,1994 })()`,
1950 true,1995 true,
1951 );1996 );
1952 if ("invalidSelector" in result) {1997 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 }
1963 return yield* fail(1998 return yield* fail(
1964 "automationWaitFor",1999 "automationWaitFor",
1965 automationError(2000 automationError("PreviewAutomationInvalidSelectorError", result.message, {
1966 "PreviewAutomationTimeoutError",2001 selector: input.selector ?? "",
1967 `Preview condition did not match within ${timeoutMs}ms.`,2002 }),
1968 ),
1969 );2003 );
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),
1971 );2024 );
1972 });2025 });
19732026
apps/server/src/mcp/McpHttpServer.test.ts+1-1
...@@ -158,7 +158,7 @@ it.effect("registers annotated tools and preserves authenticated request context...@@ -158,7 +158,7 @@ it.effect("registers annotated tools and preserves authenticated request context
158 Effect.provideService(McpSchema.McpServerClient, client),158 Effect.provideService(McpSchema.McpServerClient, client),
159 );159 );
160 expect(press.isError).toBe(false);160 expect(press.isError).toBe(false);
161 expect(press.structuredContent).toBeUndefined();161 expect(press.structuredContent).toBeNull();
162 expect(press.content).toEqual([{ type: "text", text: "null" }]);162 expect(press.content).toEqual([{ type: "text", text: "null" }]);
163 }),163 }),
164 ).pipe(Effect.provide(TestLayer)),164 ).pipe(Effect.provide(TestLayer)),
apps/server/src/mcp/McpHttpServer.ts+80-75
...@@ -13,8 +13,15 @@ import packageJson from "../../package.json" with { type: "json" };...@@ -13,8 +13,15 @@ import packageJson from "../../package.json" with { type: "json" };
13import * as McpInvocationContext from "./McpInvocationContext.ts";13import * as McpInvocationContext from "./McpInvocationContext.ts";
14import * as McpSessionRegistry from "./McpSessionRegistry.ts";14import * as McpSessionRegistry from "./McpSessionRegistry.ts";
15import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";15import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
16import { PreviewToolkitHandlersLive } from "./toolkits/preview/handlers.ts";16import {
17import { PreviewToolkit } from "./toolkits/preview/tools.ts";17 PreviewSnapshotToolkitHandlersLive,
18 PreviewStandardToolkitHandlersLive,
19} from "./toolkits/preview/handlers.ts";
20import {
21 PreviewSnapshotTool,
22 PreviewSnapshotToolkit,
23 PreviewStandardToolkit,
24} from "./toolkits/preview/tools.ts";
1825
19const unauthorized = HttpServerResponse.jsonUnsafe(26const unauthorized = HttpServerResponse.jsonUnsafe(
20 {27 {
...@@ -81,97 +88,95 @@ const McpAuthMiddlewareLive = HttpRouter.middleware<{...@@ -81,97 +88,95 @@ const McpAuthMiddlewareLive = HttpRouter.middleware<{
81 provides: McpInvocationContext.McpInvocationContext;88 provides: McpInvocationContext.McpInvocationContext;
82}>()(makeMcpAuthMiddleware).layer;89}>()(makeMcpAuthMiddleware).layer;
8390
84const registerPreviewToolkit = Effect.fn("McpHttpServer.registerPreviewToolkit")(function* () {91const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () {
85 const server = yield* McpServer.McpServer;92 const server = yield* McpServer.McpServer;
86 const built = yield* PreviewToolkit;93 const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
87 const handleTool = built.handle as unknown as (94 const built = yield* PreviewSnapshotToolkit;
88 name: keyof typeof built.tools,95 const tool = PreviewSnapshotTool;
89 payload: unknown,96 yield* server.addTool({
90 ) => Effect.Effect<97 tool: new McpSchema.Tool({
91 Stream.Stream<{ readonly encodedResult: unknown }, Error>,98 name: tool.name,
92 Error,99 description: Tool.getDescription(tool),
93 McpInvocationContext.McpInvocationContext100 inputSchema: Tool.getJsonSchema(tool),
94 >;101 annotations: {
95 for (const tool of Object.values(built.tools)) {102 ...Context.getOption(tool.annotations, Tool.Title).pipe(
96 yield* server.addTool({103 Option.map((title) => ({ title })),
97 tool: new McpSchema.Tool({104 Option.getOrUndefined,
98 name: tool.name,105 ),
99 description: Tool.getDescription(tool),106 readOnlyHint: Context.get(tool.annotations, Tool.Readonly),
100 inputSchema: Tool.getJsonSchema(tool),107 destructiveHint: Context.get(tool.annotations, Tool.Destructive),
101 annotations: {108 idempotentHint: Context.get(tool.annotations, Tool.Idempotent),
102 ...Context.getOption(tool.annotations, Tool.Title).pipe(109 openWorldHint: Context.get(tool.annotations, Tool.OpenWorld),
103 Option.map((title) => ({ title })),110 },
104 Option.getOrUndefined,111 }),
105 ),112 annotations: tool.annotations,
106 readOnlyHint: Context.get(tool.annotations, Tool.Readonly),113 handle: (payload) =>
107 destructiveHint: Context.get(tool.annotations, Tool.Destructive),114 Effect.withFiber((fiber) => {
108 idempotentHint: Context.get(tool.annotations, Tool.Idempotent),115 const invocation = Context.getUnsafe(
109 openWorldHint: Context.get(tool.annotations, Tool.OpenWorld),116 fiber.context,
110 },117 McpInvocationContext.McpInvocationContext,
111 }),118 );
112 annotations: tool.annotations,119 return built.handle("preview_snapshot", payload).pipe(
113 handle: (payload) =>
114 handleTool(tool.name as keyof typeof built.tools, payload).pipe(
115 Stream.unwrap,120 Stream.unwrap,
116 Stream.run(Sink.last()),121 Stream.run(Sink.last()),
117 Effect.flatMap(Effect.fromOption),122 Effect.flatMap(Effect.fromOption),
123 Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker),
124 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
118 Effect.matchCause({125 Effect.matchCause({
119 onFailure: (cause) =>126 onFailure: (cause) =>
120 new McpSchema.CallToolResult({127 new McpSchema.CallToolResult({
121 isError: true,128 isError: true,
122 content: [{ type: "text", text: Cause.pretty(cause) }],129 content: [{ type: "text", text: Cause.pretty(cause) }],
123 }),130 }),
124 onSuccess: (result) => {131 onSuccess: ({ encodedResult }) => {
125 if (tool.name === "preview_snapshot") {132 const snapshot = encodedResult as {
126 const snapshot = result.encodedResult as {133 readonly screenshot: {
127 readonly screenshot: {134 readonly mimeType: "image/png";
128 readonly mimeType: "image/png";135 readonly data: string;
129 readonly data: string;136 readonly width: number;
130 readonly width: number;137 readonly height: number;
131 readonly height: number;
132 };
133 readonly [key: string]: unknown;
134 };138 };
135 const { screenshot, ...page } = snapshot;139 readonly [key: string]: unknown;
136 const metadata = {140 };
137 ...page,141 const { screenshot, ...page } = snapshot;
138 screenshot: {142 const metadata = {
139 mimeType: screenshot.mimeType,143 ...page,
140 width: screenshot.width,144 screenshot: {
141 height: screenshot.height,145 mimeType: screenshot.mimeType,
142 },146 width: screenshot.width,
143 };147 height: screenshot.height,
144 return new McpSchema.CallToolResult({148 },
145 isError: false,149 };
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";
158 return new McpSchema.CallToolResult({150 return new McpSchema.CallToolResult({
159 isError: false,151 isError: false,
160 structuredContent:152 structuredContent: metadata,
161 result.encodedResult !== null && typeof result.encodedResult === "object"153 content: [
162 ? result.encodedResult154 { type: "text", text: JSON.stringify(metadata) },
163 : undefined,155 {
164 content: [{ type: "text", text: encodedResultText }],156 type: "image",
157 data: new Uint8Array(Buffer.from(screenshot.data, "base64")),
158 mimeType: screenshot.mimeType,
159 },
160 ],
165 });161 });
166 },162 },
167 }),163 }),
168 ) as unknown as Effect.Effect<McpSchema.CallToolResult, never, McpSchema.McpServerClient>,164 );
169 });165 }),
170 }166 });
171});167});
172168
173export const PreviewToolkitRegistrationLive = Layer.effectDiscard(registerPreviewToolkit()).pipe(169const PreviewStandardToolkitRegistrationLive = McpServer.toolkit(PreviewStandardToolkit).pipe(
174 Layer.provide(PreviewToolkitHandlersLive),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,
175);180);
176181
177const McpTransportLive = McpServer.layerHttp({182const McpTransportLive = McpServer.layerHttp({
apps/server/src/mcp/McpSessionRegistry.ts+35-33
...@@ -1,9 +1,10 @@...@@ -1,9 +1,10 @@
1import { ProviderInstanceId, ThreadId } from "@t3tools/contracts";1import { ProviderInstanceId, ThreadId } from "@t3tools/contracts";
2import * as Clock from "effect/Clock";
2import * as Context from "effect/Context";3import * as Context from "effect/Context";
3import * as Crypto from "effect/Crypto";4import * as Crypto from "effect/Crypto";
4import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
6import * as Ref from "effect/Ref";7import * as SynchronizedRef from "effect/SynchronizedRef";
7import { HttpServer } from "effect/unstable/http";8import { HttpServer } from "effect/unstable/http";
89
9import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";10import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
...@@ -59,15 +60,15 @@ const bytesToHex = (bytes: Uint8Array): string =>...@@ -59,15 +60,15 @@ const bytesToHex = (bytes: Uint8Array): string =>
5960
60const tokenFromBytes = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url");61const tokenFromBytes = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url");
6162
62const make = Effect.fn("McpSessionRegistry.make")(function* (63const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
63 options: McpSessionRegistryOptions = {},64 options: McpSessionRegistryOptions = {},
64) {65) {
65 const crypto = yield* Crypto.Crypto;66 const crypto = yield* Crypto.Crypto;
66 const environment = yield* ServerEnvironment;67 const environment = yield* ServerEnvironment;
67 const environmentId = yield* environment.getEnvironmentId;68 const environmentId = yield* environment.getEnvironmentId;
68 const httpServer = yield* HttpServer.HttpServer;69 const httpServer = yield* HttpServer.HttpServer;
69 const state = yield* Ref.make<RegistryState>({ records: new Map() });70 const state = yield* SynchronizedRef.make<RegistryState>({ records: new Map() });
70 const now = options.now ?? Date.now;71 const currentTimeMillis = options.now ? Effect.sync(options.now) : Clock.currentTimeMillis;
71 const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;72 const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
72 const maximumLifetimeMs = options.maximumLifetimeMs ?? DEFAULT_MAXIMUM_LIFETIME_MS;73 const maximumLifetimeMs = options.maximumLifetimeMs ?? DEFAULT_MAXIMUM_LIFETIME_MS;
73 const endpoint =74 const endpoint =
...@@ -81,21 +82,18 @@ const make = Effect.fn("McpSessionRegistry.make")(function* (...@@ -81,21 +82,18 @@ const make = Effect.fn("McpSessionRegistry.make")(function* (
81 .pipe(Effect.map(bytesToHex), Effect.orDie);82 .pipe(Effect.map(bytesToHex), Effect.orDie);
8283
83 const pruneExpired = (records: ReadonlyMap<string, CredentialRecord>, timestamp: number) => {84 const pruneExpired = (records: ReadonlyMap<string, CredentialRecord>, timestamp: number) => {
84 let changed = false;85 const next = new Map(
85 const next = new Map<string, CredentialRecord>();86 Array.from(records).filter(
86 for (const [hash, record] of records) {87 ([, record]) =>
87 if (timestamp <= record.scope.expiresAt && timestamp - record.lastUsedAt <= idleTimeoutMs) {88 timestamp <= record.scope.expiresAt && timestamp - record.lastUsedAt <= idleTimeoutMs,
88 next.set(hash, record);89 ),
89 } else {90 );
90 changed = true;91 return next.size === records.size ? records : next;
91 }
92 }
93 return changed ? next : records;
94 };92 };
9593
96 const issue: McpSessionRegistryShape["issue"] = Effect.fn("McpSessionRegistry.issue")(94 const issue: McpSessionRegistryShape["issue"] = Effect.fn("McpSessionRegistry.issue")(
97 function* (request) {95 function* (request) {
98 const issuedAt = now();96 const issuedAt = yield* currentTimeMillis;
99 const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie);97 const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie);
100 const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie);98 const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie);
101 const tokenHash = yield* hashToken(rawToken);99 const tokenHash = yield* hashToken(rawToken);
...@@ -109,7 +107,7 @@ const make = Effect.fn("McpSessionRegistry.make")(function* (...@@ -109,7 +107,7 @@ const make = Effect.fn("McpSessionRegistry.make")(function* (
109 issuedAt,107 issuedAt,
110 expiresAt,108 expiresAt,
111 };109 };
112 yield* Ref.update(state, ({ records }) => {110 yield* SynchronizedRef.update(state, ({ records }) => {
113 const next = new Map(pruneExpired(records, issuedAt));111 const next = new Map(pruneExpired(records, issuedAt));
114 next.set(tokenHash, { tokenHash, scope, lastUsedAt: issuedAt });112 next.set(tokenHash, { tokenHash, scope, lastUsedAt: issuedAt });
115 return { records: next };113 return { records: next };
...@@ -132,23 +130,20 @@ const make = Effect.fn("McpSessionRegistry.make")(function* (...@@ -132,23 +130,20 @@ const make = Effect.fn("McpSessionRegistry.make")(function* (
132 function* (rawToken) {130 function* (rawToken) {
133 if (rawToken.length === 0) return undefined;131 if (rawToken.length === 0) return undefined;
134 const tokenHash = yield* hashToken(rawToken);132 const tokenHash = yield* hashToken(rawToken);
135 const timestamp = now();133 const timestamp = yield* currentTimeMillis;
136 let resolved: McpInvocationContext.McpInvocationScope | undefined;134 return yield* SynchronizedRef.modify(state, ({ records }) => {
137 yield* Ref.update(state, ({ records }) => {
138 const current = pruneExpired(records, timestamp);135 const current = pruneExpired(records, timestamp);
139 const record = current.get(tokenHash);136 const record = current.get(tokenHash);
140 if (!record) return { records: current };137 if (!record) return [undefined, { records: current }] as const;
141 resolved = record.scope;
142 const next = new Map(current);138 const next = new Map(current);
143 next.set(tokenHash, { ...record, lastUsedAt: timestamp });139 next.set(tokenHash, { ...record, lastUsedAt: timestamp });
144 return { records: next };140 return [record.scope, { records: next }] as const;
145 });141 });
146 return resolved;
147 },142 },
148 );143 );
149144
150 const revokeWhere = (predicate: (record: CredentialRecord) => boolean) =>145 const revokeWhere = (predicate: (record: CredentialRecord) => boolean) =>
151 Ref.update(state, ({ records }) => ({146 SynchronizedRef.update(state, ({ records }) => ({
152 records: new Map(Array.from(records).filter(([, record]) => !predicate(record))),147 records: new Map(Array.from(records).filter(([, record]) => !predicate(record))),
153 }));148 }));
154149
...@@ -163,27 +158,34 @@ const make = Effect.fn("McpSessionRegistry.make")(function* (...@@ -163,27 +158,34 @@ const make = Effect.fn("McpSessionRegistry.make")(function* (
163 revokeThread: Effect.fn("McpSessionRegistry.revokeThread")(function* (threadId) {158 revokeThread: Effect.fn("McpSessionRegistry.revokeThread")(function* (threadId) {
164 yield* revokeWhere((record) => record.scope.threadId === threadId);159 yield* revokeWhere((record) => record.scope.threadId === threadId);
165 }),160 }),
166 revokeAll: Ref.set(state, { records: new Map() }),161 revokeAll: SynchronizedRef.set(state, { records: new Map() }),
167 });162 });
168});163});
169164
170let activeMcpSessionRegistry: McpSessionRegistryShape | undefined;165let activeMcpSessionRegistry: McpSessionRegistryShape | undefined;
171166
172export const layer: Layer.Layer<167const make = Effect.acquireRelease(
173 McpSessionRegistry,168 makeWithOptions().pipe(
174 never,
175 Crypto.Crypto | ServerEnvironment | HttpServer.HttpServer
176> = Layer.effect(
177 McpSessionRegistry,
178 make().pipe(
179 Effect.tap((registry) =>169 Effect.tap((registry) =>
180 Effect.sync(() => {170 Effect.sync(() => {
181 activeMcpSessionRegistry = registry;171 activeMcpSessionRegistry = registry;
182 }),172 }),
183 ),173 ),
184 ),174 ),
175 (registry) =>
176 Effect.sync(() => {
177 if (activeMcpSessionRegistry === registry) {
178 activeMcpSessionRegistry = undefined;
179 }
180 }),
185);181);
186182
183export const layer: Layer.Layer<
184 McpSessionRegistry,
185 never,
186 Crypto.Crypto | ServerEnvironment | HttpServer.HttpServer
187> = Layer.effect(McpSessionRegistry, make);
188
187export const issueActiveMcpCredential = (189export const issueActiveMcpCredential = (
188 request: McpCredentialRequest,190 request: McpCredentialRequest,
189): Effect.Effect<McpIssuedCredential | undefined> =>191): Effect.Effect<McpIssuedCredential | undefined> =>
...@@ -201,5 +203,5 @@ export const revokeAllActiveMcpCredentials = (): Effect.Effect<void> =>...@@ -201,5 +203,5 @@ export const revokeAllActiveMcpCredentials = (): Effect.Effect<void> =>
201203
202/** Exposed for tests. */204/** Exposed for tests. */
203export const __testing = {205export const __testing = {
204 make,206 make: makeWithOptions,
205};207};
apps/server/src/mcp/PreviewAutomationBroker.test.ts+3-3
...@@ -23,7 +23,7 @@ const scope = {...@@ -23,7 +23,7 @@ const scope = {
23it.effect("routes a request to the focused owner and correlates its response", () =>23it.effect("routes a request to the focused owner and correlates its response", () =>
24 Effect.scoped(24 Effect.scoped(
25 Effect.gen(function* () {25 Effect.gen(function* () {
26 const broker = yield* PreviewAutomationBroker.__testing.make();26 const broker = yield* PreviewAutomationBroker.__testing.make;
27 const requests = yield* broker.connect("client-1");27 const requests = yield* broker.connect("client-1");
28 yield* Stream.runForEach(requests, (request) =>28 yield* Stream.runForEach(requests, (request) =>
29 broker.respond({29 broker.respond({
...@@ -56,7 +56,7 @@ it.effect("routes a request to the focused owner and correlates its response", (...@@ -56,7 +56,7 @@ it.effect("routes a request to the focused owner and correlates its response", (
5656
57it.effect("rejects calls when no focused owner exists", () =>57it.effect("rejects calls when no focused owner exists", () =>
58 Effect.gen(function* () {58 Effect.gen(function* () {
59 const broker = yield* PreviewAutomationBroker.__testing.make();59 const broker = yield* PreviewAutomationBroker.__testing.make;
60 const error = yield* broker60 const error = yield* broker
61 .invoke<void>({ scope, operation: "status", input: {} })61 .invoke<void>({ scope, operation: "status", input: {} })
62 .pipe(Effect.flip);62 .pipe(Effect.flip);
...@@ -67,7 +67,7 @@ it.effect("rejects calls when no focused owner exists", () =>...@@ -67,7 +67,7 @@ it.effect("rejects calls when no focused owner exists", () =>
67it.effect("routes interactive commands to a hidden durable browser host", () =>67it.effect("routes interactive commands to a hidden durable browser host", () =>
68 Effect.scoped(68 Effect.scoped(
69 Effect.gen(function* () {69 Effect.gen(function* () {
70 const broker = yield* PreviewAutomationBroker.__testing.make();70 const broker = yield* PreviewAutomationBroker.__testing.make;
71 const requests = yield* broker.connect("client-hidden");71 const requests = yield* broker.connect("client-hidden");
72 yield* Stream.runForEach(requests, (request) =>72 yield* Stream.runForEach(requests, (request) =>
73 broker.respond({ requestId: request.requestId, ok: true }),73 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";...@@ -21,8 +21,8 @@ import * as Effect from "effect/Effect";
21import * as Layer from "effect/Layer";21import * as Layer from "effect/Layer";
22import * as Option from "effect/Option";22import * as Option from "effect/Option";
23import * as Queue from "effect/Queue";23import * as Queue from "effect/Queue";
24import * as Ref from "effect/Ref";
25import * as Stream from "effect/Stream";24import * as Stream from "effect/Stream";
25import * as SynchronizedRef from "effect/SynchronizedRef";
2626
27import * as McpInvocationContext from "./McpInvocationContext.ts";27import * as McpInvocationContext from "./McpInvocationContext.ts";
2828
...@@ -71,6 +71,7 @@ interface BrokerState {...@@ -71,6 +71,7 @@ interface BrokerState {
71 readonly clients: ReadonlyMap<string, ClientConnection>;71 readonly clients: ReadonlyMap<string, ClientConnection>;
72 readonly owners: ReadonlyMap<string, PreviewAutomationOwner>;72 readonly owners: ReadonlyMap<string, PreviewAutomationOwner>;
73 readonly pending: ReadonlyMap<string, PendingRequest>;73 readonly pending: ReadonlyMap<string, PendingRequest>;
74 readonly requestSequence: number;
74}75}
7576
76const makeResponseError = (77const makeResponseError = (
...@@ -119,33 +120,35 @@ const makeResponseError = (...@@ -119,33 +120,35 @@ const makeResponseError = (
119 }120 }
120};121};
121122
122const make = Effect.fn("PreviewAutomationBroker.make")(function* () {123const make = Effect.gen(function* PreviewAutomationBrokerMake() {
123 const state = yield* Ref.make<BrokerState>({124 const state = yield* SynchronizedRef.make<BrokerState>({
124 clients: new Map(),125 clients: new Map(),
125 owners: new Map(),126 owners: new Map(),
126 pending: new Map(),127 pending: new Map(),
128 requestSequence: 0,
127 });129 });
128 let requestSequence = 0;
129130
130 const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* (131 const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* (
131 clientId: string,132 clientId: string,
132 queue: ClientConnection["queue"],133 queue: ClientConnection["queue"],
133 ) {134 ) {
134 const toFail: PendingRequest[] = [];135 const toFail = yield* SynchronizedRef.modify(state, (current) => {
135 yield* Ref.update(state, (current) => {136 if (current.clients.get(clientId)?.queue !== queue) {
136 if (current.clients.get(clientId)?.queue !== queue) return current;137 return [[] as ReadonlyArray<PendingRequest>, current] as const;
138 }
137 const clients = new Map(current.clients);139 const clients = new Map(current.clients);
138 const owners = new Map(current.owners);140 const owners = new Map(current.owners);
139 const pending = new Map(current.pending);141 const pending = new Map(current.pending);
142 const disconnected: PendingRequest[] = [];
140 clients.delete(clientId);143 clients.delete(clientId);
141 owners.delete(clientId);144 owners.delete(clientId);
142 for (const [requestId, entry] of pending) {145 for (const [requestId, entry] of pending) {
143 if (entry.clientId === clientId) {146 if (entry.clientId === clientId) {
144 pending.delete(requestId);147 pending.delete(requestId);
145 toFail.push(entry);148 disconnected.push(entry);
146 }149 }
147 }150 }
148 return { clients, owners, pending };151 return [disconnected, { ...current, clients, owners, pending }] as const;
149 });152 });
150 yield* Effect.forEach(153 yield* Effect.forEach(
151 toFail,154 toFail,
...@@ -165,12 +168,10 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () {...@@ -165,12 +168,10 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () {
165 "PreviewAutomationBroker.connect",168 "PreviewAutomationBroker.connect",
166 )(function* (clientId) {169 )(function* (clientId) {
167 const queue = yield* Queue.unbounded<import("@t3tools/contracts").PreviewAutomationRequest>();170 const queue = yield* Queue.unbounded<import("@t3tools/contracts").PreviewAutomationRequest>();
168 let previous: ClientConnection | undefined;171 const previous = yield* SynchronizedRef.modify(state, (current) => {
169 yield* Ref.update(state, (current) => {
170 previous = current.clients.get(clientId);
171 const clients = new Map(current.clients);172 const clients = new Map(current.clients);
172 clients.set(clientId, { clientId, queue });173 clients.set(clientId, { clientId, queue });
173 return { ...current, clients };174 return [current.clients.get(clientId), { ...current, clients }] as const;
174 });175 });
175 if (previous) yield* disconnect(clientId, previous.queue);176 if (previous) yield* disconnect(clientId, previous.queue);
176 return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue)));177 return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue)));
...@@ -179,7 +180,7 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () {...@@ -179,7 +180,7 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () {
179 const reportOwner: PreviewAutomationBrokerShape["reportOwner"] = Effect.fn(180 const reportOwner: PreviewAutomationBrokerShape["reportOwner"] = Effect.fn(
180 "PreviewAutomationBroker.reportOwner",181 "PreviewAutomationBroker.reportOwner",
181 )(function* (owner) {182 )(function* (owner) {
182 yield* Ref.update(state, (current) => {183 yield* SynchronizedRef.update(state, (current) => {
183 const owners = new Map(current.owners);184 const owners = new Map(current.owners);
184 owners.set(owner.clientId, owner);185 owners.set(owner.clientId, owner);
185 return { ...current, owners };186 return { ...current, owners };
...@@ -189,7 +190,7 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () {...@@ -189,7 +190,7 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () {
189 const clearOwner: PreviewAutomationBrokerShape["clearOwner"] = Effect.fn(190 const clearOwner: PreviewAutomationBrokerShape["clearOwner"] = Effect.fn(
190 "PreviewAutomationBroker.clearOwner",191 "PreviewAutomationBroker.clearOwner",
191 )(function* (clientId) {192 )(function* (clientId) {
192 yield* Ref.update(state, (current) => {193 yield* SynchronizedRef.update(state, (current) => {
193 const owners = new Map(current.owners);194 const owners = new Map(current.owners);
194 owners.delete(clientId);195 owners.delete(clientId);
195 return { ...current, owners };196 return { ...current, owners };
...@@ -199,13 +200,12 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () {...@@ -199,13 +200,12 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () {
199 const respond: PreviewAutomationBrokerShape["respond"] = Effect.fn(200 const respond: PreviewAutomationBrokerShape["respond"] = Effect.fn(
200 "PreviewAutomationBroker.respond",201 "PreviewAutomationBroker.respond",
201 )(function* (response) {202 )(function* (response) {
202 let pending: PendingRequest | undefined;203 const pending = yield* SynchronizedRef.modify(state, (current) => {
203 yield* Ref.update(state, (current) => {204 const entry = current.pending.get(response.requestId);
204 pending = current.pending.get(response.requestId);205 if (!entry) return [undefined, current] as const;
205 if (!pending) return current;
206 const next = new Map(current.pending);206 const next = new Map(current.pending);
207 next.delete(response.requestId);207 next.delete(response.requestId);
208 return { ...current, pending: next };208 return [entry, { ...current, pending: next }] as const;
209 });209 });
210 if (!pending) return;210 if (!pending) return;
211 if (response.ok) {211 if (response.ok) {
...@@ -225,7 +225,7 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () {...@@ -225,7 +225,7 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () {
225 const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* <A = unknown>(225 const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* <A = unknown>(
226 input: Parameters<PreviewAutomationBrokerShape["invoke"]>[0],226 input: Parameters<PreviewAutomationBrokerShape["invoke"]>[0],
227 ): Effect.fn.Return<A, PreviewAutomationError> {227 ): Effect.fn.Return<A, PreviewAutomationError> {
228 const current = yield* Ref.get(state);228 const current = yield* SynchronizedRef.get(state);
229 const candidates = Array.from(current.owners.values())229 const candidates = Array.from(current.owners.values())
230 .filter(230 .filter(
231 (owner) =>231 (owner) =>
...@@ -256,48 +256,52 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () {...@@ -256,48 +256,52 @@ const make = Effect.fn("PreviewAutomationBroker.make")(function* () {
256 message: "The browser host does not have an active tab.",256 message: "The browser host does not have an active tab.",
257 });257 });
258 }258 }
259 const requestId = `preview-${requestSequence++}`;
260 const timeoutMs = input.timeoutMs ?? 15_000;259 const timeoutMs = input.timeoutMs ?? 15_000;
261 const deferred = yield* Deferred.make<unknown, PreviewAutomationError>();260 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}`;
263 const pending = new Map(next.pending);263 const pending = new Map(next.pending);
264 pending.set(requestId, { clientId: owner.clientId, deferred });264 pending.set(requestId, { clientId: owner.clientId, deferred });
265 return { ...next, pending };265 return [requestId, { ...next, pending, requestSequence: next.requestSequence + 1 }] as const;
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,
274 });266 });
275 if (!offered) {267 const removePending = SynchronizedRef.update(state, (next) => {
276 return yield* new PreviewAutomationUnavailableError({268 if (!next.pending.has(requestId)) return next;
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) => {
282 const pending = new Map(next.pending);269 const pending = new Map(next.pending);
283 pending.delete(requestId);270 pending.delete(requestId);
284 return { ...next, pending };271 return { ...next, pending };
285 });272 });
286 return yield* Option.match(result, {273 const awaitResponse = Effect.fn("PreviewAutomationBroker.awaitResponse")(function* () {
287 onNone: () =>274 const offered = yield* Queue.offer(connection.queue, {
288 Effect.fail(275 requestId,
289 new PreviewAutomationTimeoutError({276 threadId: input.scope.threadId,
290 message: `Preview automation timed out after ${timeoutMs}ms.`,277 tabId: input.tabId ?? owner.tabId ?? undefined,
291 }),278 operation: input.operation,
292 ),279 input: input.input,
293 onSome: (value) => Effect.succeed(value as A),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 });
294 });297 });
298 return yield* awaitResponse().pipe(Effect.ensuring(removePending));
295 });299 });
296300
297 return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke });301 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
302/** Exposed for tests. */306/** Exposed for tests. */
303export const __testing = {307export const __testing = {
apps/server/src/mcp/toolkits/preview/handlers.ts+20-8
...@@ -9,7 +9,7 @@ import type {...@@ -9,7 +9,7 @@ import type {
99
10import * as McpInvocationContext from "../../McpInvocationContext.ts";10import * as McpInvocationContext from "../../McpInvocationContext.ts";
11import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";11import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
12import { PreviewToolkit } from "./tools.ts";12import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts";
1313
14const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>(14const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>(
15 operation: PreviewAutomationOperation,15 operation: PreviewAutomationOperation,
...@@ -30,7 +30,7 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>(...@@ -30,7 +30,7 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>(
30 });30 });
31});31});
3232
33export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer({33const handlers = {
34 preview_status: () => invoke<PreviewAutomationStatus>("status", {}),34 preview_status: () => invoke<PreviewAutomationStatus>("status", {}),
35 preview_open: (input) =>35 preview_open: (input) =>
36 invoke<PreviewAutomationStatus>("open", {36 invoke<PreviewAutomationStatus>("open", {
...@@ -40,12 +40,24 @@ export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer({...@@ -40,12 +40,24 @@ export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer({
40 }),40 }),
41 preview_navigate: (input) => invoke<PreviewAutomationStatus>("navigate", input, input.timeoutMs),41 preview_navigate: (input) => invoke<PreviewAutomationStatus>("navigate", input, input.timeoutMs),
42 preview_snapshot: () => invoke<PreviewAutomationSnapshot>("snapshot", {}),42 preview_snapshot: () => invoke<PreviewAutomationSnapshot>("snapshot", {}),
43 preview_click: (input) => invoke<void>("click", 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),44 preview_type: (input) => invoke<void>("type", input, input.timeoutMs).pipe(Effect.as(null)),
45 preview_press: (input) => invoke<void>("press", input),45 preview_press: (input) => invoke<void>("press", input).pipe(Effect.as(null)),
46 preview_scroll: (input) => invoke<void>("scroll", input),46 preview_scroll: (input) => invoke<void>("scroll", input).pipe(Effect.as(null)),
47 preview_evaluate: (input) => invoke<unknown>("evaluate", input),47 preview_evaluate: (input) =>
48 preview_wait_for: (input) => invoke<void>("waitFor", input, input.timeoutMs),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)),
49 preview_recording_start: () => invoke<PreviewAutomationRecordingStatus>("recordingStart", {}),51 preview_recording_start: () => invoke<PreviewAutomationRecordingStatus>("recordingStart", {}),
50 preview_recording_stop: () => invoke<PreviewAutomationRecordingArtifact>("recordingStop", {}),52 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,
51});61});
62
63export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer(handlers);
apps/server/src/mcp/toolkits/preview/tools.ts+21
...@@ -84,6 +84,7 @@ export const PreviewClickTool = browserTool(...@@ -84,6 +84,7 @@ export const PreviewClickTool = browserTool(
84 description:84 description:
85 "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.",85 "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.",
86 parameters: PreviewAutomationClickInput,86 parameters: PreviewAutomationClickInput,
87 success: Schema.Null,
87 failure: PreviewAutomationError,88 failure: PreviewAutomationError,
88 dependencies,89 dependencies,
89 }).annotate(Tool.Title, "Click preview page"),90 }).annotate(Tool.Title, "Click preview page"),
...@@ -94,6 +95,7 @@ export const PreviewTypeTool = browserTool(...@@ -94,6 +95,7 @@ export const PreviewTypeTool = browserTool(
94 description:95 description:
95 "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.",96 "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.",
96 parameters: PreviewAutomationTypeInput,97 parameters: PreviewAutomationTypeInput,
98 success: Schema.Null,
97 failure: PreviewAutomationError,99 failure: PreviewAutomationError,
98 dependencies,100 dependencies,
99 }).annotate(Tool.Title, "Type into preview page"),101 }).annotate(Tool.Title, "Type into preview page"),
...@@ -104,6 +106,7 @@ export const PreviewPressTool = browserTool(...@@ -104,6 +106,7 @@ export const PreviewPressTool = browserTool(
104 description:106 description:
105 "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.",107 "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.",
106 parameters: PreviewAutomationPressInput,108 parameters: PreviewAutomationPressInput,
109 success: Schema.Null,
107 failure: PreviewAutomationError,110 failure: PreviewAutomationError,
108 dependencies,111 dependencies,
109 }).annotate(Tool.Title, "Press key in preview page"),112 }).annotate(Tool.Title, "Press key in preview page"),
...@@ -114,6 +117,7 @@ export const PreviewScrollTool = safeBrowserTool(...@@ -114,6 +117,7 @@ export const PreviewScrollTool = safeBrowserTool(
114 description:117 description:
115 "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.",118 "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.",
116 parameters: PreviewAutomationScrollInput,119 parameters: PreviewAutomationScrollInput,
120 success: Schema.Null,
117 failure: PreviewAutomationError,121 failure: PreviewAutomationError,
118 dependencies,122 dependencies,
119 }).annotate(Tool.Title, "Scroll preview page"),123 }).annotate(Tool.Title, "Scroll preview page"),
...@@ -135,6 +139,7 @@ export const PreviewWaitForTool = readonlyBrowserTool(...@@ -135,6 +139,7 @@ export const PreviewWaitForTool = readonlyBrowserTool(
135 description:139 description:
136 "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.",140 "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.",
137 parameters: PreviewAutomationWaitForInput,141 parameters: PreviewAutomationWaitForInput,
142 success: Schema.Null,
138 failure: PreviewAutomationError,143 failure: PreviewAutomationError,
139 dependencies,144 dependencies,
140 }).annotate(Tool.Title, "Wait for preview page condition"),145 }).annotate(Tool.Title, "Wait for preview page condition"),
...@@ -173,3 +178,19 @@ export const PreviewToolkit = Toolkit.make(...@@ -173,3 +178,19 @@ export const PreviewToolkit = Toolkit.make(
173 PreviewRecordingStartTool,178 PreviewRecordingStartTool,
174 PreviewRecordingStopTool,179 PreviewRecordingStopTool,
175);180);
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: {...@@ -127,7 +127,7 @@ const buildIdleSnapshot = (input: {
127 updatedAt: input.updatedAt,127 updatedAt: input.updatedAt,
128});128});
129129
130const make = Effect.fn("PreviewManager.make")(function* () {130const make = Effect.gen(function* PreviewManagerMake() {
131 const stateRef = yield* SynchronizedRef.make<ManagerState>(initialState);131 const stateRef = yield* SynchronizedRef.make<ManagerState>(initialState);
132 // Unbounded PubSub is fine here — events are tiny and we don't want to132 // Unbounded PubSub is fine here — events are tiny and we don't want to
133 // block publishers if a subscriber is slow. WS clients backpressure on133 // block publishers if a subscriber is slow. WS clients backpressure on
...@@ -357,6 +357,6 @@ const make = Effect.fn("PreviewManager.make")(function* () {...@@ -357,6 +357,6 @@ const make = Effect.fn("PreviewManager.make")(function* () {
357 events,357 events,
358 subscribeEvents: PubSub.subscribe(eventsPubSub),358 subscribeEvents: PubSub.subscribe(eventsPubSub),
359 } satisfies PreviewManagerShape;359 } 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...@@ -249,20 +249,18 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall
249 );249 );
250250
251 it.effect(251 it.effect(
252 "retain() drives an immediate broadcast to subscribers",252 "retain drives an immediate broadcast to subscribers",
253 Effect.fn("PortScannerTest.retainBroadcastsImmediately")(function* () {253 Effect.fn("PortScannerTest.retainBroadcastsImmediately")(function* () {
254 yield* windowsPlatform;254 yield* windowsPlatform;
255 const { port } = yield* commonDevServer;255 const { port } = yield* commonDevServer;
256 const received: number[] = [];256 const received: number[] = [];
257 const scanner = yield* PortScanner.PortDiscovery;257 const scanner = yield* PortScanner.PortDiscovery;
258 const unsubscribe = yield* scanner.subscribe((servers) =>258 yield* scanner.subscribe((servers) =>
259 Effect.sync(() => {259 Effect.sync(() => {
260 for (const server of servers) received.push(server.port);260 for (const server of servers) received.push(server.port);
261 }),261 }),
262 );262 );
263 const release = yield* scanner.retain();263 yield* scanner.retain;
264 unsubscribe();
265 release();
266 expect(received).toContain(port);264 expect(received).toContain(port);
267 }),265 }),
268 );266 );
apps/server/src/preview/PortScanner.ts+74-55
...@@ -8,13 +8,13 @@...@@ -8,13 +8,13 @@
8 * Windows / lsof missing: checks a curated list of common dev ports through8 * Windows / lsof missing: checks a curated list of common dev ports through
9 * the shared Net service.9 * the shared Net service.
10 *10 *
11 * Polling is reference-counted via `retain()`. A single layer-scoped fiber11 * Polling is reference-counted via scoped `retain`. A single layer-scoped fiber
12 * polls forever, but each tick is a no-op when the retain count is zero.12 * polls forever, but each tick is a no-op when the retain count is zero.
13 */13 */
14import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts";14import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts";
15import * as Net from "@t3tools/shared/Net";15import * as Net from "@t3tools/shared/Net";
16import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview";16import { 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
19import { ProcessRunner } from "../processRunner.ts";19import { ProcessRunner } from "../processRunner.ts";
2020
...@@ -22,8 +22,8 @@ export interface PortDiscoveryShape {...@@ -22,8 +22,8 @@ export interface PortDiscoveryShape {
22 readonly scan: () => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>;22 readonly scan: () => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>;
23 readonly subscribe: (23 readonly subscribe: (
24 listener: (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>,24 listener: (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>,
25 ) => Effect.Effect<() => void>;25 ) => Effect.Effect<void, never, Scope.Scope>;
26 readonly retain: () => Effect.Effect<() => void>;26 readonly retain: Effect.Effect<void, never, Scope.Scope>;
27 readonly registerTerminalProcesses: (input: {27 readonly registerTerminalProcesses: (input: {
28 readonly threadId: string;28 readonly threadId: string;
29 readonly terminalId: string;29 readonly terminalId: string;
...@@ -51,6 +51,15 @@ type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect...@@ -51,6 +51,15 @@ type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect
5151
52interface ScannerState {52interface ScannerState {
53 readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>;53 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;
54}63}
5564
56interface TerminalProcessOwner {65interface TerminalProcessOwner {
...@@ -170,24 +179,15 @@ const serversEqual = (...@@ -170,24 +179,15 @@ const serversEqual = (
170 return true;179 return true;
171};180};
172181
173const make = Effect.fn("PortDiscovery.make")(function* () {182const make = Effect.gen(function* PortDiscoveryMake() {
174 const net = yield* Net.NetService;183 const net = yield* Net.NetService;
175 const processRunner = yield* ProcessRunner;184 const processRunner = yield* ProcessRunner;
176 const stateRef = yield* Ref.make<ScannerState>({185 const stateRef = yield* Ref.make<ScannerState>({
177 lastSnapshot: [],186 lastSnapshot: [],
187 listeners: new Set(),
188 terminalProcesses: new Map(),
189 retainCount: 0,
178 });190 });
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
192 const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () {192 const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () {
193 const results = yield* Effect.forEach(193 const results = yield* Effect.forEach(
...@@ -214,8 +214,9 @@ const make = Effect.fn("PortDiscovery.make")(function* () {...@@ -214,8 +214,9 @@ const make = Effect.fn("PortDiscovery.make")(function* () {
214 });214 });
215215
216 const scanOnce = Effect.fn("PortDiscovery.scan")(function* () {216 const scanOnce = Effect.fn("PortDiscovery.scan")(function* () {
217 const state = yield* Ref.get(stateRef);
217 const terminalByProcessId = new Map<number, TerminalProcessOwner>();218 const terminalByProcessId = new Map<number, TerminalProcessOwner>();
218 for (const registration of terminalProcesses.values()) {219 for (const registration of state.terminalProcesses.values()) {
219 for (const processId of registration.processIds) {220 for (const processId of registration.processIds) {
220 terminalByProcessId.set(processId, registration.owner);221 terminalByProcessId.set(processId, registration.owner);
221 }222 }
...@@ -254,17 +255,23 @@ const make = Effect.fn("PortDiscovery.make")(function* () {...@@ -254,17 +255,23 @@ const make = Effect.fn("PortDiscovery.make")(function* () {
254 return yield* probeCommonPorts();255 return yield* probeCommonPorts();
255 });256 });
256257
257 const broadcast = (servers: ReadonlyArray<DiscoveredLocalServer>): Effect.Effect<void> =>258 const broadcast = Effect.fn("PortDiscovery.broadcast")(function* (
258 Effect.forEach(Array.from(listeners), (listener) => listener(servers), { discard: true });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
260 const pollTick = Effect.fn("PortDiscovery.pollTick")(265 const pollTick = Effect.fn("PortDiscovery.pollTick")(
261 function* () {266 function* () {
262 if (retainCount <= 0) return;267 if ((yield* Ref.get(stateRef)).retainCount <= 0) return;
263 const next = yield* scanOnce();268 const next = yield* scanOnce();
264 const state = yield* Ref.get(stateRef);269 const changed = yield* Ref.modify(stateRef, (state) =>
265 if (serversEqual(state.lastSnapshot, next)) return;270 serversEqual(state.lastSnapshot, next)
266 yield* Ref.update(stateRef, (s) => ({ ...s, lastSnapshot: next }));271 ? [false, state]
267 yield* broadcast(next);272 : [true, { ...state, lastSnapshot: next }],
273 );
274 if (changed) yield* broadcast(next);
268 },275 },
269 Effect.catchCause((cause: Cause.Cause<never>) =>276 Effect.catchCause((cause: Cause.Cause<never>) =>
270 Effect.logWarning("preview port scan failed", Cause.pretty(cause)),277 Effect.logWarning("preview port scan failed", Cause.pretty(cause)),
...@@ -275,58 +282,70 @@ const make = Effect.fn("PortDiscovery.make")(function* () {...@@ -275,58 +282,70 @@ const make = Effect.fn("PortDiscovery.make")(function* () {
275 // currently retained, so the cost is one Ref.get every POLL_INTERVAL.282 // currently retained, so the cost is one Ref.get every POLL_INTERVAL.
276 yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL))));283 yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL))));
277284
278 const retain: PortDiscoveryShape["retain"] = Effect.fn("PortDiscovery.retain")(function* () {285 const acquireRetention = Effect.fn("PortDiscovery.retain")(function* () {
279 const wasIdle = retainCount === 0;286 const wasIdle = yield* Ref.modify(stateRef, (state) => [
280 retainCount += 1;287 state.retainCount === 0,
288 { ...state, retainCount: state.retainCount + 1 },
289 ]);
281 if (wasIdle) {290 if (wasIdle) {
282 // Run an immediate scan + broadcast so the new retainer doesn't have291 // Run an immediate scan + broadcast so the new retainer doesn't have
283 // to wait up to POLL_INTERVAL for the first emission.292 // to wait up to POLL_INTERVAL for the first emission.
284 yield* pollTick();293 yield* pollTick();
285 }294 }
286 let released = false;
287 return () => {
288 if (released) return;
289 released = true;
290 retainCount = Math.max(0, retainCount - 1);
291 };
292 });295 });
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
294 const subscribe: PortDiscoveryShape["subscribe"] = Effect.fn("PortDiscovery.subscribe")(304 const subscribe: PortDiscoveryShape["subscribe"] = Effect.fn("PortDiscovery.subscribe")(
295 function* (listener) {305 (listener) =>
296 return yield* Effect.sync(() => {306 Effect.acquireRelease(
297 listeners.add(listener);307 Ref.update(stateRef, (state) => ({
298 return () => {308 ...state,
299 listeners.delete(listener);309 listeners: new Set([...state.listeners, listener]),
300 };310 })),
301 });311 () =>
302 },312 Ref.update(stateRef, (state) => {
313 const listeners = new Set(state.listeners);
314 listeners.delete(listener);
315 return { ...state, listeners };
316 }),
317 ),
303 );318 );
304319
305 const registerTerminalProcesses: PortDiscoveryShape["registerTerminalProcesses"] = Effect.fn(320 const registerTerminalProcesses: PortDiscoveryShape["registerTerminalProcesses"] = Effect.fn(
306 "PortDiscovery.registerTerminalProcesses",321 "PortDiscovery.registerTerminalProcesses",
307 )(function* (input) {322 )(function* (input) {
308 yield* Effect.sync(() => {323 const owner = {
309 const owner = {324 threadId: ThreadId.make(input.threadId),
310 threadId: ThreadId.make(input.threadId),325 terminalId: input.terminalId,
311 terminalId: input.terminalId,326 };
312 };327 const processIds = new Set(
313 const processIds = new Set(328 input.processIds.filter((processId) => Number.isInteger(processId) && processId > 0),
314 input.processIds.filter((processId) => Number.isInteger(processId) && processId > 0),329 );
315 );330 yield* Ref.update(stateRef, (state) => {
331 const terminalProcesses = new Map(state.terminalProcesses);
316 const key = terminalOwnerKey(owner);332 const key = terminalOwnerKey(owner);
317 if (processIds.size === 0) {333 if (processIds.size === 0) {
318 terminalProcesses.delete(key);334 terminalProcesses.delete(key);
319 return;335 } else {
336 terminalProcesses.set(key, { owner, processIds });
320 }337 }
321 terminalProcesses.set(key, { owner, processIds });338 return { ...state, terminalProcesses };
322 });339 });
323 });340 });
324341
325 const unregisterTerminal: PortDiscoveryShape["unregisterTerminal"] = Effect.fn(342 const unregisterTerminal: PortDiscoveryShape["unregisterTerminal"] = Effect.fn(
326 "PortDiscovery.unregisterTerminal",343 "PortDiscovery.unregisterTerminal",
327 )(function* (input) {344 )(function* (input) {
328 yield* Effect.sync(() => {345 yield* Ref.update(stateRef, (state) => {
346 const terminalProcesses = new Map(state.terminalProcesses);
329 terminalProcesses.delete(terminalOwnerKey(input));347 terminalProcesses.delete(terminalOwnerKey(input));
348 return { ...state, terminalProcesses };
330 });349 });
331 });350 });
332351
...@@ -337,9 +356,9 @@ const make = Effect.fn("PortDiscovery.make")(function* () {...@@ -337,9 +356,9 @@ const make = Effect.fn("PortDiscovery.make")(function* () {
337 registerTerminalProcesses,356 registerTerminalProcesses,
338 unregisterTerminal,357 unregisterTerminal,
339 } satisfies PortDiscoveryShape;358 } 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
344/** Exposed for tests. */363/** Exposed for tests. */
345export const __testing = {364export const __testing = {
apps/server/src/server.test.ts+2-2
...@@ -683,8 +683,8 @@ const buildAppUnderTest = (options?: {...@@ -683,8 +683,8 @@ const buildAppUnderTest = (options?: {
683 }),683 }),
684 Layer.mock(PortScanner.PortDiscovery)({684 Layer.mock(PortScanner.PortDiscovery)({
685 scan: () => Effect.succeed([]),685 scan: () => Effect.succeed([]),
686 subscribe: () => Effect.succeed(() => {}),686 subscribe: () => Effect.void,
687 retain: () => Effect.succeed(() => {}),687 retain: Effect.void,
688 registerTerminalProcesses: () => Effect.void,688 registerTerminalProcesses: () => Effect.void,
689 unregisterTerminal: () => Effect.void,689 unregisterTerminal: () => Effect.void,
690 }),690 }),
apps/server/src/ws.ts+14-22
...@@ -1425,29 +1425,21 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>...@@ -1425,29 +1425,21 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
1425 observeRpcStream(1425 observeRpcStream(
1426 WS_METHODS.subscribeDiscoveredLocalServers,1426 WS_METHODS.subscribeDiscoveredLocalServers,
1427 Stream.callback<DiscoveredLocalServerList>((queue) =>1427 Stream.callback<DiscoveredLocalServerList>((queue) =>
1428 Effect.acquireRelease(1428 Effect.gen(function* () {
1429 Effect.gen(function* () {1429 yield* portDiscovery.retain;
1430 const release = yield* portDiscovery.retain();1430 const initial = yield* portDiscovery.scan();
1431 const initial = yield* portDiscovery.scan();1431 const initialScannedAt = DateTime.formatIso(yield* DateTime.now);
1432 const initialScannedAt = DateTime.formatIso(yield* DateTime.now);1432 yield* Queue.offer(queue, {
1433 yield* Queue.offer(queue, {1433 servers: initial,
1434 servers: initial,1434 scannedAt: initialScannedAt,
1435 scannedAt: initialScannedAt,1435 });
1436 });1436 yield* portDiscovery.subscribe((servers) =>
1437 const unsubscribe = yield* portDiscovery.subscribe((servers) =>1437 Effect.gen(function* () {
1438 Effect.gen(function* () {1438 const scannedAt = DateTime.formatIso(yield* DateTime.now);
1439 const scannedAt = DateTime.formatIso(yield* DateTime.now);1439 yield* Queue.offer(queue, { servers, scannedAt });
1440 yield* Queue.offer(queue, { servers, scannedAt });
1441 }),
1442 );
1443 return { unsubscribe, release };
1444 }),
1445 ({ unsubscribe, release }) =>
1446 Effect.sync(() => {
1447 unsubscribe();
1448 release();
1449 }),1440 }),
1450 ),1441 );
1442 }),
1451 ),1443 ),
1452 { "rpc.aggregate": "preview" },1444 { "rpc.aggregate": "preview" },
1453 ),1445 ),