Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions apps/server/src/mcp/PreviewAutomationBroker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,7 @@ it.effect("pins a provider session to its initial host despite later focus chang
environmentId: scope.environmentId,
connectionId: "connection-stale",
focused: true,
liveTabs: [{ threadId: scope.threadId, tabId: PreviewTabId.make("stale-tab") }],
});
expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe(
"second",
Expand Down Expand Up @@ -725,6 +726,190 @@ it.effect("pins a provider session to its initial host despite later focus chang
),
);

it.effect("prefers the live tab owner for new sessions without moving existing leases", () =>
Effect.scoped(
Effect.gen(function* () {
const broker = yield* makeBroker;
const connections = new Map<string, string>();
for (const clientId of ["owner", "other"]) {
const requests = requestsFrom(
yield* broker.connect(makeHost({ clientId })),
(connectionId) => connections.set(clientId, connectionId),
);
yield* Stream.runForEach(requests, (request) =>
broker.respond({
clientId,
connectionId: request.connectionId,
requestId: request.requestId,
ok: true,
result: clientId,
}),
).pipe(Effect.forkScoped);
}
yield* Effect.yieldNow;
yield* broker.focusHost({
clientId: "owner",
environmentId: scope.environmentId,
connectionId: connections.get("owner")!,
focused: false,
liveTabs: [
{ threadId: scope.threadId, tabId: PreviewTabId.make("signed-in"), visible: true },
],
});
yield* broker.focusHost({
clientId: "other",
environmentId: scope.environmentId,
connectionId: connections.get("other")!,
focused: true,
liveTabs: [
{ threadId: scope.threadId, tabId: PreviewTabId.make("signed-in"), visible: false },
{ threadId: scope.threadId, tabId: PreviewTabId.make("other-signed-in"), visible: false },
{
threadId: ThreadId.make("another-thread"),
tabId: PreviewTabId.make("different-tab"),
visible: true,
},
],
});
expect(yield* broker.invoke<string>({ scope, operation: "evaluate", input: {} })).toBe(
"owner",
);
const wrongHostError = yield* broker
.invoke<string>({
scope,
tabId: PreviewTabId.make("other-signed-in"),
operation: "evaluate",
input: {},
})
.pipe(Effect.flip);
expect(wrongHostError).toBeInstanceOf(PreviewAutomationNoAvailableHostError);
expect(yield* broker.invoke<string>({ scope, operation: "evaluate", input: {} })).toBe(
"owner",
);
expect(
yield* broker.invoke<string>({
scope: { ...scope, providerSessionId: "explicit-owner" },
tabId: PreviewTabId.make("signed-in"),
operation: "snapshot",
input: {},
}),
).toBe("owner");
expect(
yield* broker.invoke<string>({
scope: { ...scope, providerSessionId: "other-tab" },
tabId: PreviewTabId.make("different-tab"),
operation: "evaluate",
input: {},
}),
).toBe("other");

yield* broker.focusHost({
clientId: "owner",
environmentId: scope.environmentId,
connectionId: connections.get("owner")!,
focused: false,
liveTabs: [],
});
expect(yield* broker.invoke<string>({ scope, operation: "evaluate", input: {} })).toBe(
"owner",
);
expect(
yield* broker.invoke<string>({
scope: { ...scope, providerSessionId: "after-tab-closed" },
operation: "evaluate",
input: {},
}),
).toBe("other");
}),
),
);

it.effect("retains capability-rich host preference when no host owns the target tab", () =>
Effect.scoped(
Effect.gen(function* () {
const broker = yield* makeBroker;
let focusedConnectionId = "";
for (const [clientId, supportedOperations] of [
["focused", ["status"]],
["background", ["status", "resize"]],
] as const) {
const requests = requestsFrom(
yield* broker.connect(makeHost({ clientId, supportedOperations })),
(connectionId) => {
if (clientId === "focused") focusedConnectionId = connectionId;
},
);
yield* Stream.runForEach(requests, (request) =>
broker.respond({
clientId,
connectionId: request.connectionId,
requestId: request.requestId,
ok: true,
result: clientId,
}),
).pipe(Effect.forkScoped);
}
yield* Effect.yieldNow;
yield* broker.focusHost({
clientId: "focused",
environmentId: scope.environmentId,
connectionId: focusedConnectionId,
focused: true,
});
expect(yield* broker.invoke<string>({ scope, operation: "status", input: {} })).toBe(
"background",
);
}),
),
);

it.effect("fails closed when a named tab's owner cannot perform the requested operation", () =>
Effect.scoped(
Effect.gen(function* () {
const broker = yield* makeBroker;
let ownerConnectionId = "";
for (const [clientId, supportedOperations] of [
["owner", ["status"]],
["other", ["status", "snapshot"]],
] as const) {
const requests = requestsFrom(
yield* broker.connect(makeHost({ clientId, supportedOperations })),
(connectionId) => {
if (clientId === "owner") ownerConnectionId = connectionId;
},
);
yield* Stream.runForEach(requests, (request) =>
broker.respond({
clientId,
connectionId: request.connectionId,
requestId: request.requestId,
ok: true,
result: clientId,
}),
).pipe(Effect.forkScoped);
}
yield* Effect.yieldNow;
const tabId = PreviewTabId.make("named-tab");
yield* broker.focusHost({
clientId: "owner",
environmentId: scope.environmentId,
connectionId: ownerConnectionId,
focused: false,
liveTabs: [{ threadId: scope.threadId, tabId, visible: true }],
});
const error = yield* broker
.invoke<void>({
scope: { ...scope, providerSessionId: "fresh-explicit-tab" },
tabId,
operation: "snapshot",
input: {},
})
.pipe(Effect.flip);
expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError);
}),
),
);

it.effect("does not route new operations to legacy hosts that did not advertise support", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down Expand Up @@ -921,6 +1106,7 @@ it.effect("fails over a pinned provider session only after its host disconnects"
environmentId: scope.environmentId,
connectionId: firstConnectionId,
focused: true,
liveTabs: [{ threadId: scope.threadId, tabId: firstTabId }],
});
expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({
host: "first",
Expand Down
36 changes: 29 additions & 7 deletions apps/server/src/mcp/PreviewAutomationBroker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ interface ClientConnection {
readonly environmentId: PreviewAutomationHost["environmentId"];
readonly supportedOperations: ReadonlySet<PreviewAutomationOperation>;
readonly focused: boolean;
readonly liveTabs: NonNullable<PreviewAutomationHostFocus["liveTabs"]>;
readonly focusOrder: number;
readonly queue: Queue.Queue<PreviewAutomationStreamEvent, Cause.Done>;
}
Expand Down Expand Up @@ -376,6 +377,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
environmentId: host.environmentId,
supportedOperations: new Set(host.supportedOperations ?? PREVIEW_AUTOMATION_V1_OPERATIONS),
focused: false,
liveTabs: [],
focusOrder: 0,
queue,
};
Expand Down Expand Up @@ -432,6 +434,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
clients.set(host.clientId, {
...currentHost,
focused: host.focused,
liveTabs: host.liveTabs ?? currentHost.liveTabs,
focusOrder: host.focused ? focusSequence : currentHost.focusOrder,
});
return { ...current, clients, focusSequence };
Expand Down Expand Up @@ -492,19 +495,38 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
// operation is not silently moved to a newer client: the caller gets a
// capability failure and can deliberately start a fresh provider
// session. A dead lease is pruned above and may fail over.
const ownsTargetTab = (host: ClientConnection, visibleOnly = false) =>
host.liveTabs.some(
(tab) =>
tab.threadId === input.scope.threadId &&
(!visibleOnly || tab.visible === true) &&
(input.tabId === undefined || tab.tabId === input.tabId),
);
const environmentHosts = Array.from(current.clients.values()).filter(
(host) => host.environmentId === input.scope.environmentId,
);
const explicitTabOwners =
input.tabId === undefined ? [] : environmentHosts.filter((host) => ownsTargetTab(host));
const assignedOwnsNamedTab =
explicitTabOwners.length === 0 ||
explicitTabOwners.some((host) => host.clientId === assignedConnection?.clientId);
// A named tab with a reporting owner must stay on an owning host. If
// none of those owners supports the operation, fail instead of sending
// the request to a different browser with unrelated cookies and DOM.
const candidateHosts = explicitTabOwners.length > 0 ? explicitTabOwners : environmentHosts;
const connection =
hasLiveAssignment && supportsOperation(assignedConnection, input.operation)
hasLiveAssignment &&
assignedOwnsNamedTab &&
supportsOperation(assignedConnection, input.operation)
? assignedConnection
: hasLiveAssignment
? undefined
: Array.from(current.clients.values())
.filter(
(host) =>
host.environmentId === input.scope.environmentId &&
supportsOperation(host, input.operation),
)
: candidateHosts
.filter((host) => supportsOperation(host, input.operation))
.sort(
(left, right) =>
Number(ownsTargetTab(right, true)) - Number(ownsTargetTab(left, true)) ||
Number(ownsTargetTab(right)) - Number(ownsTargetTab(left)) ||
right.supportedOperations.size - left.supportedOperations.size ||
Number(right.focused) - Number(left.focused) ||
right.focusOrder - left.focusOrder,
Expand Down
Loading
Loading