Skip to content

Commit e22e078

Browse files
committed
Merge upstream/main into main
2 parents 718b269 + 29366ea commit e22e078

93 files changed

Lines changed: 1632 additions & 716 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/release.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,9 @@ jobs:
331331
# No Electron setup here: `t3` (apps/server) has no Electron dependency
332332
# and none of its tests touch the runtime. Only the non-server `test`
333333
# job, which covers @t3tools/desktop, needs the download.
334+
- name: Install Chromium for server browser integration tests
335+
run: node apps/server/node_modules/playwright-core/cli.js install --with-deps --only-shell chromium
336+
334337
- name: Test
335338
run: vp run --filter t3 test --shard ${{ matrix.shard }}/${{ strategy.job-total }}
336339

‎apps/desktop/src/electron/ElectronProtocol.test.ts‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { assert, describe, it } from "@effect/vitest";
22
import * as Cause from "effect/Cause";
33
import * as Effect from "effect/Effect";
4+
import * as Fiber from "effect/Fiber";
45
import * as NodeServices from "@effect/platform-node/NodeServices";
56
import * as FileSystem from "effect/FileSystem";
67
import * as Layer from "effect/Layer";
8+
import * as TestClock from "effect/testing/TestClock";
79
import { beforeEach, vi } from "vite-plus/test";
810

911
const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({
@@ -174,7 +176,11 @@ describe("ElectronProtocol", () => {
174176
targetOrigin: new URL("http://127.0.0.1:5733/"),
175177
clerkFrontendApiHostname: undefined,
176178
});
177-
return yield* Effect.promise(() => handler!(new Request("t3code-dev://app/")));
179+
const fiber = yield* Effect.forkChild(
180+
Effect.promise(() => handler!(new Request("t3code-dev://app/"))),
181+
);
182+
yield* TestClock.adjust("50 millis");
183+
return yield* Fiber.join(fiber);
178184
}),
179185
);
180186

@@ -183,6 +189,50 @@ describe("ElectronProtocol", () => {
183189
}).pipe(Effect.provide(layerProtocol)),
184190
);
185191

192+
it.effect("rejects with the last renderer target failure after 50ms and 150ms retries", () =>
193+
Effect.gen(function* () {
194+
let handler: ((request: Request) => Promise<Response>) | undefined;
195+
handleMock.mockImplementation((_scheme, nextHandler) => {
196+
handler = nextHandler;
197+
});
198+
const lastFailure = new Error("connect ECONNREFUSED 127.0.0.1:5733 (3)");
199+
netFetchMock
200+
.mockRejectedValueOnce(new Error("connect ECONNREFUSED 127.0.0.1:5733 (1)"))
201+
.mockRejectedValueOnce(new Error("connect ECONNREFUSED 127.0.0.1:5733 (2)"))
202+
.mockRejectedValueOnce(lastFailure);
203+
204+
const rejection = yield* Effect.scoped(
205+
Effect.gen(function* () {
206+
const protocol = yield* ElectronProtocol.ElectronProtocol;
207+
yield* protocol.registerDesktopProtocol({
208+
scheme: "t3code-dev",
209+
targetOrigin: new URL("http://127.0.0.1:5733/"),
210+
clerkFrontendApiHostname: undefined,
211+
});
212+
const fiber = yield* Effect.forkChild(
213+
Effect.promise(() =>
214+
handler!(new Request("t3code-dev://app/")).then(
215+
() => null,
216+
(error: unknown) => error,
217+
),
218+
),
219+
);
220+
yield* TestClock.adjust("49 millis");
221+
assert.equal(netFetchMock.mock.calls.length, 1);
222+
yield* TestClock.adjust("1 millis");
223+
assert.equal(netFetchMock.mock.calls.length, 2);
224+
yield* TestClock.adjust("149 millis");
225+
assert.equal(netFetchMock.mock.calls.length, 2);
226+
yield* TestClock.adjust("1 millis");
227+
return yield* Fiber.join(fiber);
228+
}),
229+
);
230+
231+
assert.strictEqual(rejection, lastFailure);
232+
assert.equal(netFetchMock.mock.calls.length, 3);
233+
}).pipe(Effect.provide(layerProtocol)),
234+
);
235+
186236
it.effect("preserves protocol registration failures", () =>
187237
Effect.gen(function* () {
188238
const cause = new Error("protocol registration failed");

‎apps/desktop/src/electron/ElectronProtocol.ts‎

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import * as Effect from "effect/Effect";
33
import * as FileSystem from "effect/FileSystem";
44
import * as Layer from "effect/Layer";
55
import * as Option from "effect/Option";
6-
import * as NodeTimersPromises from "node:timers/promises";
76
import * as Path from "effect/Path";
87
import * as Mime from "effect/http/Mime";
98
import * as Ref from "effect/Ref";
9+
import * as Schedule from "effect/Schedule";
1010
import * as Schema from "effect/Schema";
1111
import * as Scope from "effect/Scope";
1212

@@ -150,11 +150,29 @@ const registerDesktopSchemePrivileges = Effect.sync(registerDesktopSchemePrivile
150150

151151
export const layerSchemePrivileges = Layer.effectDiscard(registerDesktopSchemePrivileges);
152152

153-
async function proxyRequest(
153+
class ElectronProtocolFetchError extends Schema.TaggedError<ElectronProtocolFetchError>()(
154+
"ElectronProtocolFetchError",
155+
{ cause: Schema.Defect() },
156+
) {}
157+
158+
const netFetch = (url: string, init: RequestInit) =>
159+
Effect.tryPromise({
160+
try: () => Electron.net.fetch(url, init),
161+
catch: (cause) => new ElectronProtocolFetchError({ cause }),
162+
});
163+
164+
// The dev renderer target can briefly refuse connections while Vite restarts:
165+
// retry idempotent requests after 50ms, then 150ms, and keep the last failure.
166+
const fetchWithTransientRetry = (url: string, init: RequestInit) =>
167+
netFetch(url, init).pipe(
168+
Effect.retry({ schedule: Schedule.exponential("50 millis", 3), times: 2 }),
169+
);
170+
171+
const proxyRequest = Effect.fn("desktop.protocol.proxyRequest")(function* (
154172
request: Request,
155173
targetOrigin: URL,
156174
contentSecurityPolicy: string,
157-
): Promise<Response> {
175+
) {
158176
const requestUrl = new URL(request.url);
159177
if (requestUrl.host !== DESKTOP_HOST) {
160178
return new Response(null, { status: 404 });
@@ -190,12 +208,10 @@ async function proxyRequest(
190208
}
191209
const response =
192210
request.method === "GET" || request.method === "HEAD"
193-
? await fetchWithTransientRetry(targetUrl.toString(), init)
194-
: await Electron.net.fetch(targetUrl.toString(), init);
211+
? yield* fetchWithTransientRetry(targetUrl.toString(), init)
212+
: yield* netFetch(targetUrl.toString(), init);
195213
return withContentSecurityPolicy(response, contentSecurityPolicy);
196-
}
197-
198-
const TRANSIENT_FETCH_RETRY_DELAYS_MS = [0, 50, 150] as const;
214+
});
199215

200216
// Serves the packaged web client without a backend: files resolve within the
201217
// asset directory, and any other path falls back to index.html so the SPA
@@ -238,24 +254,6 @@ const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* (
238254
});
239255
});
240256

241-
async function fetchWithTransientRetry(url: string, init: RequestInit): Promise<Response> {
242-
let lastError: unknown;
243-
244-
for (const delayMs of TRANSIENT_FETCH_RETRY_DELAYS_MS) {
245-
if (delayMs > 0) {
246-
await NodeTimersPromises.setTimeout(delayMs);
247-
}
248-
249-
try {
250-
return await Electron.net.fetch(url, init);
251-
} catch (error) {
252-
lastError = error;
253-
}
254-
}
255-
256-
throw lastError;
257-
}
258-
259257
/** @public Service construction is part of the canonical Effect module API. */
260258
export const make = Effect.gen(function* () {
261259
const registered = yield* Ref.make(false);
@@ -278,7 +276,14 @@ export const make = Effect.gen(function* () {
278276
contentSecurityPolicy,
279277
);
280278
}
281-
return proxyRequest(request, input.targetOrigin, contentSecurityPolicy);
279+
// Reject with net.fetch's own error, as an unproxied fetch would.
280+
return runPromise(
281+
proxyRequest(request, input.targetOrigin, contentSecurityPolicy).pipe(
282+
Effect.catchTags({
283+
ElectronProtocolFetchError: (error) => Effect.die(error.cause),
284+
}),
285+
),
286+
);
282287
});
283288
},
284289
catch: (cause) => new ElectronProtocolRegistrationError({ scheme: input.scheme, cause }),

‎apps/desktop/src/ssh/DesktopSshEnvironment.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from "@t3tools/ssh/errors";
1919
import * as SshTunnel from "@t3tools/ssh/tunnel";
2020
import * as Context from "effect/Context";
21+
import type * as Crypto from "effect/Crypto";
2122
import * as Effect from "effect/Effect";
2223
import * as FileSystem from "effect/FileSystem";
2324
import * as Layer from "effect/Layer";
@@ -29,6 +30,7 @@ import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts";
2930

3031
export type DesktopSshEnvironmentRuntimeServices =
3132
| ChildProcessSpawner.ChildProcessSpawner
33+
| Crypto.Crypto
3234
| FileSystem.FileSystem
3335
| Path.Path
3436
| HttpClient.HttpClient

‎apps/server/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"@effect/platform-node-shared": "catalog:",
3535
"@ff-labs/fff-node": "0.9.4",
3636
"@napi-rs/keyring": "^1.3.0",
37+
"@noble/hashes": "catalog:",
3738
"@opencode-ai/sdk": "^1.3.15",
3839
"@opencode/client": "2.0.23",
3940
"@opencode/protocol": "2.0.23",

‎apps/server/src/auth/ReusableDevAuth.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no timingSafeEqual.
12
import * as NodeCrypto from "node:crypto";
23
import { AuthSessionId } from "@t3tools/contracts";
34
import * as DateTime from "effect/DateTime";

‎apps/server/src/auth/utils.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
AuthClientPresentationMetadata,
55
} from "@t3tools/contracts";
66
import type * as HttpServerRequest from "effect/http/HttpServerRequest";
7+
// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac or timingSafeEqual.
78
import * as NodeCrypto from "node:crypto";
89
import * as Base64Url from "effect/encoding/Base64Url";
910
import * as Result from "effect/Result";

‎apps/server/src/automation/AutomationService.ts‎

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@
1010
*
1111
* @module AutomationService
1212
*/
13-
import * as NodeCrypto from "node:crypto";
14-
1513
import {
1614
Automation,
1715
AUTOMATION_MAX_RUNS,
@@ -655,21 +653,22 @@ const make = Effect.gen(function* () {
655653
})
656654
.pipe(
657655
Effect.as(true),
658-
Effect.catchTag("OrchestratorDispatchError", (error) =>
659-
threads.getThreadEventSequence(threadId).pipe(
660-
// Only a thread that moved past the read is decided again against a fresh one;
661-
// any other rejection waits for the next event instead of retrying in a loop.
662-
Effect.flatMap((sequence) =>
663-
sequence > expectedSequence
664-
? recheck(threadId)
665-
: Effect.logWarning("automation thread delete rejected", {
666-
threadId,
667-
cause: error,
668-
}),
656+
Effect.catchTags({
657+
OrchestratorDispatchError: (error) =>
658+
threads.getThreadEventSequence(threadId).pipe(
659+
// Only a thread that moved past the read is decided again against a fresh one;
660+
// any other rejection waits for the next event instead of retrying in a loop.
661+
Effect.flatMap((sequence) =>
662+
sequence > expectedSequence
663+
? recheck(threadId)
664+
: Effect.logWarning("automation thread delete rejected", {
665+
threadId,
666+
cause: error,
667+
}),
668+
),
669+
Effect.as(false),
669670
),
670-
Effect.as(false),
671-
),
672-
),
671+
}),
673672
);
674673
if (!deleted) return;
675674
yield* recordPendingRuns(threadId, [], "deleted");
@@ -723,7 +722,7 @@ const make = Effect.gen(function* () {
723722
path: path.join(
724723
config.worktreesDir,
725724
path.basename(workspaceRoot),
726-
`pr-${pullRequestNumber}-${NodeCrypto.randomBytes(4).toString("hex")}`,
725+
`pr-${pullRequestNumber}-${Buffer.from(yield* crypto.randomBytes(4).pipe(Effect.orDie)).toString("hex")}`,
727726
),
728727
});
729728
const worktreePath = created.worktree.path;

‎apps/server/src/checkpointing/CheckpointDiffQuery.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
12
import { assert, it, vi } from "@effect/vitest";
23
import { CheckpointRef, CheckpointScopeId, RunId, ThreadId } from "@t3tools/contracts";
34
import * as Effect from "effect/Effect";
@@ -59,6 +60,7 @@ function layerFor(input: {
5960
}),
6061
),
6162
),
63+
Layer.provideMerge(NodeCrypto.layer),
6264
);
6365
}
6466

@@ -80,7 +82,7 @@ it.effect("computes V2 run diffs from projected checkpoint scopes", () => {
8082
});
8183
assert.deepEqual(diffCheckpoints.mock.calls[0]?.[0], {
8284
cwd: "/repo",
83-
fromCheckpointRef: checkpointRefForScopeOrdinal({
85+
fromCheckpointRef: yield* checkpointRefForScopeOrdinal({
8486
scopeId: firstScopeId,
8587
ordinalWithinScope: 0,
8688
}),

‎apps/server/src/checkpointing/CheckpointDiffQuery.ts‎

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
type ThreadId,
1616
} from "@t3tools/contracts";
1717
import * as Context from "effect/Context";
18+
import * as Crypto from "effect/Crypto";
1819
import * as Effect from "effect/Effect";
1920
import * as Layer from "effect/Layer";
2021
import * as Schema from "effect/Schema";
@@ -77,6 +78,7 @@ function buildTurnDiffResult(
7778
export const make = Effect.gen(function* () {
7879
const threads = yield* ThreadManagement.ThreadManagementService;
7980
const checkpointStore = yield* CheckpointStore.CheckpointStore;
81+
const crypto = yield* Crypto.Crypto;
8082

8183
const getTurnDiff: CheckpointDiffQuery["Service"]["getTurnDiff"] = Effect.fn("getTurnDiff")(
8284
function* (input) {
@@ -155,21 +157,20 @@ export const make = Effect.gen(function* () {
155157
});
156158
}
157159

160+
// The root scope is shared by every run in this thread. Its runId
161+
// tracks the latest owner, while ordinal zero stays the baseline.
162+
const firstScope =
163+
input.fromTurnCount === 0
164+
? projection.checkpointScopes.find((scope) => scope.kind === "root_run")
165+
: undefined;
158166
const fromCheckpointRef =
159167
input.fromTurnCount === 0
160-
? (() => {
161-
// The root scope is shared by every run in this thread. Its
162-
// runId tracks the latest owner, while ordinal zero stays the baseline.
163-
const firstScope = projection.checkpointScopes.find(
164-
(scope) => scope.kind === "root_run",
165-
);
166-
return firstScope === undefined
167-
? undefined
168-
: checkpointRefForScopeOrdinal({
169-
scopeId: firstScope.id,
170-
ordinalWithinScope: 0,
171-
});
172-
})()
168+
? firstScope === undefined
169+
? undefined
170+
: yield* checkpointRefForScopeOrdinal({
171+
scopeId: firstScope.id,
172+
ordinalWithinScope: 0,
173+
}).pipe(Effect.provideService(Crypto.Crypto, crypto))
173174
: readyCheckpoints.find((checkpoint) => checkpoint.appRunOrdinal === input.fromTurnCount)
174175
?.ref;
175176
if (fromCheckpointRef === undefined) {

0 commit comments

Comments
 (0)