Skip to content

Commit ce7b565

Browse files
Merge remote-tracking branch 'origin/main' into pr-32719
2 parents 12d0972 + 0a9abdd commit ce7b565

11 files changed

Lines changed: 694 additions & 87 deletions

‎apps/desktop/src/preview/DesktopBrowserHost.test.ts‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ const command = (
3434
const makeDebuggee = (
3535
options: {
3636
readonly deferFocus?: boolean;
37+
/** The guest is still loading its first page. */
38+
readonly loading?: boolean;
3739
readonly refuse?: (method: string, sessionId: string | undefined) => boolean;
3840
} = {},
3941
) => {
@@ -69,10 +71,13 @@ const makeDebuggee = (
6971
sendWaiters.shift()?.();
7072
}),
7173
});
74+
let destroyed = false;
7275
const webContents = Object.assign(new NodeEvents.EventEmitter(), {
7376
getURL: () => "http://localhost/",
7477
getTitle: () => "Page",
7578
getUserAgent: () => "Electron",
79+
isLoadingMainFrame: () => options.loading === true,
80+
isDestroyed: () => destroyed,
7681
});
7782
return {
7883
tab: {
@@ -81,6 +86,12 @@ const makeDebuggee = (
8186
},
8287
emit: (method: string, params: unknown) => emitter.emit("message", {}, method, params, ""),
8388
navigate: () => webContents.emit("did-navigate"),
89+
domReady: () => webContents.emit("dom-ready"),
90+
destroy: () => {
91+
destroyed = true;
92+
},
93+
failLoad: (code: number) =>
94+
webContents.emit("did-fail-load", {}, code, "Failed", "http://localhost/", true),
8495
/** Ids of the commands the debugger received, in order. */
8596
sent,
8697
focusEmulation,
@@ -144,6 +155,71 @@ describe("DesktopBrowserHost", () => {
144155
}),
145156
);
146157

158+
it.effect("announces a guest still loading its first page only once that page is up", () =>
159+
Effect.gen(function* () {
160+
const host = yield* DesktopBrowserHost.make;
161+
const loading = makeDebuggee({ loading: true });
162+
const other = { ...key, tabId: "tab-2" };
163+
const reader = yield* takeEvents(host, 3).pipe(Effect.forkScoped);
164+
165+
yield* Effect.yieldNow;
166+
host.attach(key, loading.tab);
167+
host.setShown(key, true);
168+
host.attach(other, makeDebuggee().tab);
169+
loading.domReady();
170+
171+
expect(yield* Fiber.join(reader)).toEqual([
172+
{ type: "attached", ...other },
173+
{ type: "attached", ...key },
174+
{ type: "shown", ...key, shown: true },
175+
]);
176+
}),
177+
);
178+
179+
it.effect(
180+
"keeps a loading guest unannounced across a replaced navigation and a new backend",
181+
() =>
182+
Effect.gen(function* () {
183+
const host = yield* DesktopBrowserHost.make;
184+
const loading = makeDebuggee({ loading: true });
185+
const other = { ...key, tabId: "tab-2" };
186+
host.attach(key, loading.tab);
187+
loading.failLoad(-3);
188+
const reader = yield* takeEvents(host, 2).pipe(Effect.forkScoped);
189+
190+
yield* Effect.yieldNow;
191+
host.attach(other, makeDebuggee().tab);
192+
loading.domReady();
193+
194+
expect(yield* Fiber.join(reader)).toEqual([
195+
{ type: "attached", ...other },
196+
{ type: "attached", ...key },
197+
]);
198+
}),
199+
);
200+
201+
it.effect(
202+
"announces a first page that never comes up, but never a tab closed or destroyed first",
203+
() =>
204+
Effect.gen(function* () {
205+
const host = yield* DesktopBrowserHost.make;
206+
const closed = { ...key, tabId: "tab-2" };
207+
const destroyed = { ...key, tabId: "tab-3" };
208+
const destroyedGuest = makeDebuggee({ loading: true });
209+
const reader = yield* takeEvents(host, 1).pipe(Effect.forkScoped);
210+
211+
yield* Effect.yieldNow;
212+
host.attach(closed, makeDebuggee({ loading: true }).tab);
213+
host.detach(closed);
214+
host.attach(destroyed, destroyedGuest.tab);
215+
destroyedGuest.destroy();
216+
host.attach(key, makeDebuggee({ loading: true }).tab);
217+
yield* TestClock.adjust("5 seconds");
218+
219+
expect(yield* Fiber.join(reader)).toEqual([{ type: "attached", ...key }]);
220+
}),
221+
);
222+
147223
it.effect("re-sends shown to a backend that starts later and when a tab attaches again", () =>
148224
Effect.gen(function* () {
149225
const host = yield* DesktopBrowserHost.make;

‎apps/desktop/src/preview/DesktopBrowserHost.ts‎

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import * as Context from "effect/Context";
99
import * as NodePath from "node:path";
1010
import * as Effect from "effect/Effect";
11+
import * as Fiber from "effect/Fiber";
1112
import * as Layer from "effect/Layer";
1213
import * as Option from "effect/Option";
1314
import * as PubSub from "effect/PubSub";
@@ -26,6 +27,9 @@ const AGENT_INPUT_COMMAND =
2627
const AGENT_INPUT_METHOD = /^(?:Input\.|Page\.navigate$|Runtime\.(?:evaluate|callFunctionOn)$)/;
2728
const AGENT_DOWNLOAD_WINDOW_MS = 5_000;
2829
const FOCUS_IDLE = "5 seconds";
30+
/** Well inside the server's 10 s DESKTOP_ATTACH_TIMEOUT, for a first page that never reports it is up. */
31+
const FIRST_PAGE_TIMEOUT = "5 seconds";
32+
const ERR_ABORTED = -3;
2933

3034
export interface DesktopBrowserTabKey {
3135
readonly threadId: string;
@@ -78,6 +82,8 @@ interface AttachedTab {
7882
sessionId: string,
7983
) => void;
8084
readonly onNewDocument: () => void;
85+
/** Stops waiting for the first page; set until the tab is announced. */
86+
stopWaiting: (() => void) | null;
8187
}
8288

8389
export class DesktopBrowserHost extends Context.Service<
@@ -413,11 +419,13 @@ export const make = Effect.gen(function* () {
413419
const tab = tabs.get(id);
414420
if (!tab) return;
415421
tabs.delete(id);
422+
const announced = tab.stopWaiting === null;
423+
tab.stopWaiting?.();
416424
tab.relay = null;
417425
tab.debuggee.debugger.off("message", tab.onMessage);
418426
tab.debuggee.webContents.off("did-navigate", tab.onNewDocument);
419427
syncPainting(tab);
420-
emit({ type: "detached", ...key });
428+
if (announced) emit({ type: "detached", ...key });
421429
};
422430

423431
const attach = (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => {
@@ -455,13 +463,53 @@ export const make = Effect.gen(function* () {
455463
dropFocusHolds(tab);
456464
if (tab.screenshotTurn !== null) nextScreenshot(tab);
457465
},
466+
stopWaiting: null,
458467
};
459468
tabs.set(id, tab);
460469
debuggee.debugger.on("message", tab.onMessage);
461470
debuggee.webContents.on("did-navigate", tab.onNewDocument);
462-
emit({ type: "attached", ...key });
463-
const shown = shownTabs.get(id);
464-
if (shown !== undefined) emit({ type: "shown", ...key, shown });
471+
const announce = () => {
472+
emit({ type: "attached", ...key });
473+
const shown = shownTabs.get(id);
474+
if (shown !== undefined) emit({ type: "shown", ...key, shown });
475+
};
476+
if (!debuggee.webContents.isLoadingMainFrame()) {
477+
announce();
478+
return;
479+
}
480+
// Electron does not pause a guest for the debugger, so Playwright connecting during its first
481+
// commit builds its utility world in the discarded document and every wait on it hangs.
482+
const { webContents } = debuggee;
483+
// ERR_ABORTED is a newer navigation replacing this one, whose commit is still to come.
484+
const onFailLoad = (
485+
_event: Electron.Event,
486+
code: number,
487+
_description: string,
488+
_url: string,
489+
isMainFrame: boolean,
490+
) => {
491+
if (isMainFrame && code !== ERR_ABORTED) ready();
492+
};
493+
const ready = () => {
494+
if (tab.stopWaiting === null) return;
495+
// The manager's cleanup for a destroyed guest can still be queued behind another tab's attach.
496+
if (webContents.isDestroyed()) return detach(key);
497+
tab.stopWaiting();
498+
announce();
499+
};
500+
const timeout = runFork(
501+
Effect.sleep(FIRST_PAGE_TIMEOUT).pipe(Effect.andThen(Effect.sync(ready))),
502+
);
503+
tab.stopWaiting = () => {
504+
tab.stopWaiting = null;
505+
webContents.off("dom-ready", ready);
506+
webContents.off("did-stop-loading", ready);
507+
webContents.off("did-fail-load", onFailLoad);
508+
runFork(Fiber.interrupt(timeout));
509+
};
510+
webContents.on("dom-ready", ready);
511+
webContents.on("did-stop-loading", ready);
512+
webContents.on("did-fail-load", onFailLoad);
465513
};
466514

467515
const setShown = (key: DesktopBrowserTabKey, shown: boolean | undefined) => {
@@ -472,7 +520,7 @@ export const make = Effect.gen(function* () {
472520
}
473521
if (shownTabs.get(id) === shown) return;
474522
shownTabs.set(id, shown);
475-
if (tabs.has(id)) emit({ type: "shown", ...key, shown });
523+
if (tabs.get(id)?.stopWaiting === null) emit({ type: "shown", ...key, shown });
476524
};
477525

478526
const handleCommandLine = (line: string) =>
@@ -511,6 +559,8 @@ export const make = Effect.gen(function* () {
511559
[...tabs.values()],
512560
(tab) => {
513561
dropRelay(tab);
562+
// Announced once its first page is up.
563+
if (tab.stopWaiting !== null) return Effect.void;
514564
const shown = shownTabs.get(keyOf(tab.key));
515565
return PubSub.publish(outbox, { type: "attached", ...tab.key }).pipe(
516566
Effect.andThen(

‎apps/server/scripts/migrate-dev-db.test.ts‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,33 @@ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(functio
3838
'{"lineage":{"parentThreadId":"stopped-thread","relationshipToParent":"fork","rootThreadId":"stopped-thread"}}';
3939
const subagentPayload =
4040
'{"lineage":{"parentThreadId":"subagent-parent","relationshipToParent":"subagent","rootThreadId":"subagent-parent"},"forkedFrom":{"type":"node","nodeId":"node-1"}}';
41+
const watchedPayload = JSON.stringify({
42+
pullRequests: [
43+
{
44+
host: "github.com",
45+
repository: "owner/repo",
46+
number: 7,
47+
url: "https://github.com/owner/repo/pull/7",
48+
source: "agent",
49+
linkedAt: "2026-08-01T00:00:00.000Z",
50+
snapshot: null,
51+
stack: null,
52+
watch: {
53+
startedAt: "2026-08-01T00:00:00.000Z",
54+
headSha: null,
55+
failedChecks: [],
56+
passed: false,
57+
passedChecks: [],
58+
remarksThrough: "2026-08-01T00:00:00.000Z",
59+
remarkIds: [],
60+
conflicting: false,
61+
wakes: 0,
62+
},
63+
},
64+
],
65+
});
4166
const threads = [
42-
["stopped-thread", "project-kept", "completed", "{}", "2026-08-01"],
67+
["stopped-thread", "project-kept", "completed", watchedPayload, "2026-08-01"],
4368
["fork-thread", "project-kept", "completed", forkPayload, "2026-08-02"],
4469
["running-thread", "project-kept", "running", "{}", "2026-08-05"],
4570
["settled-thread", "project-kept", "completed", '{"settledAt":"2026-08-01"}', "2026-08-05"],
@@ -129,6 +154,11 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => {
129154
SELECT stream_id FROM orchestration_events ORDER BY stream_id`;
130155
const sessions = yield* sql<{ provider_session_id: string }>`
131156
SELECT provider_session_id FROM orchestration_v2_projection_provider_sessions`;
157+
const [pullRequest] = yield* sql<{ url: string | null; watch: string | null }>`
158+
SELECT
159+
json_extract(payload_json, '$.pullRequests[0].url') AS url,
160+
json_extract(payload_json, '$.pullRequests[0].watch') AS watch
161+
FROM orchestration_v2_projection_threads WHERE thread_id = 'stopped-thread'`;
132162
const [leftovers] = yield* sql<{
133163
auth: number;
134164
tasks: number;
@@ -143,7 +173,7 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => {
143173
(SELECT COUNT(*) FROM scheduled_task_webhook_pending) AS webhooks,
144174
(SELECT COUNT(*) FROM orchestration_v2_projection_context_transfers) AS transfers,
145175
(SELECT COUNT(*) FROM peer_environment_links) AS links`;
146-
return { threads, events, sessions, leftovers };
176+
return { threads, events, sessions, pullRequest, leftovers };
147177
}),
148178
);
149179
assert.deepStrictEqual(
@@ -158,6 +188,11 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => {
158188
kept.sessions.map((row) => row.provider_session_id),
159189
["session-shared"],
160190
);
191+
assert.deepStrictEqual(
192+
kept.pullRequest,
193+
{ url: "https://github.com/owner/repo/pull/7", watch: null },
194+
"copied pull request links keep their history but stop being watched",
195+
);
161196
assert.deepStrictEqual(kept.leftovers, {
162197
auth: 0,
163198
tasks: 0,

‎apps/server/scripts/migrate-dev-db.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,13 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra
381381
AND stream_id NOT IN (SELECT thread_id FROM kept_threads))
382382
OR (aggregate_kind = 'project'
383383
AND stream_id NOT IN (SELECT project_id FROM kept_projects))`;
384+
yield* sql`UPDATE orchestration_v2_projection_threads
385+
SET payload_json = json_set(payload_json, '$.pullRequests', (
386+
SELECT json_group_array(json_remove(link.value, '$.watch') ORDER BY link.key)
387+
FROM json_each(payload_json, '$.pullRequests') AS link))
388+
WHERE EXISTS (
389+
SELECT 1 FROM json_each(payload_json, '$.pullRequests') AS link
390+
WHERE json_extract(link.value, '$.watch') IS NOT NULL)`;
384391
for (const table of CLEARED_TABLES) {
385392
yield* sql`DELETE FROM ${sql(table)}`;
386393
}

0 commit comments

Comments
 (0)