authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-18 18:23:56-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 18:23:56-07:00
log9cb586acd0494c4f47f60045ee42ef25edb5a07c
tree5a304f3dcc315778614a454fbf824542fa38f283
parente74c668d786ffc14f40a9540cd3aede02ba5d62e
signature Signed by PGP key B5690EEEBB952194

refactor: remove obsolete code (#9917)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

116 files changed, 260 insertions(+), 4095 deletions(-)

apps/desktop/src/app/DesktopAppIdentity.test.ts-1
......@@ -57,7 +57,6 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
5757 }),
5858 setAppUserModelId: () => Effect.void,
5959 getAppMetrics: Effect.succeed([]),
60 isDefaultProtocolClient: () => Effect.succeed(false),
6160 setAsDefaultProtocolClient: () => Effect.succeed(true),
6261 setDesktopName: () => Effect.void,
6362 setDockIcon: (iconPath) =>
apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts+27-7
......@@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest";
33import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform";
44import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
55import * as Effect from "effect/Effect";
6import * as Encoding from "effect/Encoding";
67import * as FileSystem from "effect/FileSystem";
78import * as Path from "effect/Path";
89import * as Layer from "effect/Layer";
......@@ -22,6 +23,11 @@ const textEncoder = new TextEncoder();
2223const decodeConnectionCatalog = Schema.decodeEffect(
2324 Schema.fromJsonString(ConnectionCatalogDocument),
2425);
26const encodeLegacySavedEnvironments = Schema.encodeEffect(
27 Schema.fromJsonString(
28 Schema.Struct({ version: Schema.Literal(1), records: Schema.Array(Schema.Unknown) }),
29 ),
30);
2531function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref<boolean> | null = null) {
2632 return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, {
2733 isEncryptionAvailable: Effect.succeed(available),
......@@ -125,7 +131,8 @@ describe("DesktopConnectionCatalogStore", () => {
125131 withStore(
126132 Effect.gen(function* () {
127133 const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore;
128 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
134 const environment = yield* DesktopEnvironment.DesktopEnvironment;
135 const fileSystem = yield* FileSystem.FileSystem;
129136 const records: readonly PersistedSavedEnvironmentRecord[] = [
130137 {
131138 environmentId: EnvironmentId.make("relay-environment"),
......@@ -159,11 +166,21 @@ describe("DesktopConnectionCatalogStore", () => {
159166 lastConnectedAt: null,
160167 },
161168 ];
162 yield* savedEnvironments.setRegistry(records);
163 assert.isTrue(
164 yield* savedEnvironments.setSecret({
165 environmentId: EnvironmentId.make("bearer-environment"),
166 secret: "legacy-token",
169 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
170 yield* fileSystem.writeFileString(
171 environment.savedEnvironmentRegistryPath,
172 yield* encodeLegacySavedEnvironments({
173 version: 1,
174 records: records.map((record) =>
175 record.environmentId === "bearer-environment"
176 ? {
177 ...record,
178 encryptedBearerToken: Encoding.encodeBase64(
179 textEncoder.encode("encrypted:legacy-token"),
180 ),
181 }
182 : record,
183 ),
167184 }),
168185 );
169186
......@@ -218,7 +235,10 @@ describe("DesktopConnectionCatalogStore", () => {
218235 assert.equal(catalog.credentials[0].credential.token, "legacy-token");
219236 }
220237
221 yield* savedEnvironments.setRegistry([]);
238 yield* fileSystem.writeFileString(
239 environment.savedEnvironmentRegistryPath,
240 '{"version":1,"records":[]}',
241 );
222242 assert.deepEqual(yield* store.get, migrated);
223243 }),
224244 ),
apps/desktop/src/app/DesktopLifecycle.test.ts-1
......@@ -43,7 +43,6 @@ function makeElectronAppLayer(
4343 setAboutPanelOptions: () => Effect.void,
4444 setAppUserModelId: () => Effect.void,
4545 getAppMetrics: Effect.succeed([]),
46 isDefaultProtocolClient: () => Effect.succeed(false),
4746 setAsDefaultProtocolClient: () => Effect.succeed(true),
4847 setDesktopName: () => Effect.void,
4948 setDockIcon: () => Effect.void,
apps/desktop/src/backend/DesktopBackendManager.test.ts+2-4
......@@ -165,9 +165,7 @@ function makeTestInstance(input: MakeInstanceInput) {
165165 latest: Effect.succeed(Option.none()),
166166 changes: Stream.empty,
167167 encoded: input.desktopTelemetryStream ?? Stream.empty,
168 handleControl: () => Effect.void,
169 handleControlForSource: (_sourceId, message) =>
170 (input.desktopTelemetryPublisher?.handleControl ?? (() => Effect.void))(message),
168 handleControlForSource: () => Effect.void,
171169 removeControlSource: () => Effect.void,
172170 publishUpdateReport: () => Effect.void,
173171 updateRequests: Stream.empty,
......@@ -596,7 +594,7 @@ describe("DesktopBackendManager", () => {
596594 const instance = yield* makeTestInstance({
597595 spawnerLayer,
598596 desktopTelemetryPublisher: {
599 handleControl: (message) =>
597 handleControlForSource: (_sourceId, message) =>
600598 message.type === "setDiagnosticsDemand"
601599 ? Deferred.succeed(handled, message.enabled).pipe(Effect.asVoid)
602600 : Effect.void,
apps/desktop/src/backend/DesktopBackendPool.test.ts-1
......@@ -70,7 +70,6 @@ function makePoolLayer(
7070 latest: Effect.succeed(Option.none()),
7171 changes: Stream.empty,
7272 encoded: Stream.empty,
73 handleControl: () => Effect.void,
7473 handleControlForSource: () => Effect.void,
7574 removeControlSource: () => Effect.void,
7675 publishUpdateReport: () => Effect.void,
apps/desktop/src/electron/ElectronApp.test.ts-3
......@@ -10,7 +10,6 @@ const {
1010 getAppPathMock,
1111 getSystemLocaleMock,
1212 getVersionMock,
13 isDefaultProtocolClientMock,
1413 onMock,
1514 quitMock,
1615 relaunchMock,
......@@ -32,7 +31,6 @@ const {
3231 getAppPathMock: vi.fn(() => "/app"),
3332 getSystemLocaleMock: vi.fn(() => "en-GB"),
3433 getVersionMock: vi.fn(() => "1.2.3"),
35 isDefaultProtocolClientMock: vi.fn(() => false),
3634 onMock: vi.fn(),
3735 quitMock: vi.fn(),
3836 relaunchMock: vi.fn(),
......@@ -64,7 +62,6 @@ vi.mock("electron", () => ({
6462 getAppPath: getAppPathMock,
6563 getSystemLocale: getSystemLocaleMock,
6664 getVersion: getVersionMock,
67 isDefaultProtocolClient: isDefaultProtocolClientMock,
6865 isPackaged: true,
6966 name: "T3 Code",
7067 on: onMock,
apps/desktop/src/electron/ElectronApp.ts-3
......@@ -64,7 +64,6 @@ export class ElectronApp extends Context.Service<
6464 ) => Effect.Effect<void>;
6565 readonly setAppUserModelId: (id: string) => Effect.Effect<void>;
6666 readonly getAppMetrics: Effect.Effect<ReadonlyArray<Electron.ProcessMetric>>;
67 readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect<boolean>;
6867 readonly setAsDefaultProtocolClient: (
6968 protocol: string,
7069 path?: string,
......@@ -166,8 +165,6 @@ export const make = ElectronApp.of({
166165 Electron.app.setAppUserModelId(id);
167166 }),
168167 getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()),
169 isDefaultProtocolClient: (protocol) =>
170 Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)),
171168 setAsDefaultProtocolClient: (protocol, path, args) =>
172169 Effect.sync(() => {
173170 if (path === undefined) {
apps/desktop/src/settings/DesktopSavedEnvironments.test.ts+36-170
......@@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
22import { assert, describe, it } from "@effect/vitest";
33import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
44import * as Effect from "effect/Effect";
5import * as Encoding from "effect/Encoding";
56import * as FileSystem from "effect/FileSystem";
67import * as Path from "effect/Path";
78import * as Layer from "effect/Layer";
......@@ -39,16 +40,29 @@ const SavedEnvironmentRegistryDocumentProbe = Schema.Struct({
3940const SavedEnvironmentRegistryDocumentProbeJson = Schema.fromJsonString(
4041 SavedEnvironmentRegistryDocumentProbe,
4142);
42const decodeSavedEnvironmentRegistryDocumentProbe = Schema.decodeEffect(
43 SavedEnvironmentRegistryDocumentProbeJson,
44);
4543const encodeSavedEnvironmentRegistryDocumentProbe = Schema.encodeEffect(
4644 SavedEnvironmentRegistryDocumentProbeJson,
4745);
46
47const seedSavedEnvironmentRegistry = Effect.fn(function* (encryptedBearerToken?: string) {
48 const environment = yield* DesktopEnvironment.DesktopEnvironment;
49 const fileSystem = yield* FileSystem.FileSystem;
50 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
51 const encoded = yield* encodeSavedEnvironmentRegistryDocumentProbe({
52 version: 1,
53 records: [
54 {
55 ...savedRegistryRecord,
56 ...(encryptedBearerToken === undefined ? {} : { encryptedBearerToken }),
57 },
58 ],
59 });
60 yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, `${encoded}\n`);
61});
62
4863function makeSafeStorageLayer(input: {
4964 readonly available: boolean;
5065 readonly availabilityError?: unknown;
51 readonly encryptError?: unknown;
5266 readonly decryptError?: unknown;
5367}) {
5468 return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, {
......@@ -60,14 +74,7 @@ function makeSafeStorageLayer(input: {
6074 cause: input.availabilityError,
6175 }),
6276 ),
63 encryptString: (value) =>
64 input.encryptError === undefined
65 ? Effect.succeed(textEncoder.encode(`enc:${value}`))
66 : Effect.fail(
67 new ElectronSafeStorage.ElectronSafeStorageEncryptError({
68 cause: input.encryptError,
69 }),
70 ),
77 encryptString: (value) => Effect.succeed(textEncoder.encode(`enc:${value}`)),
7178 decryptString: (value) => {
7279 if (input.decryptError !== undefined) {
7380 return Effect.fail(
......@@ -96,7 +103,6 @@ function makeLayer(
96103 options?: {
97104 readonly availableSecretStorage?: boolean;
98105 readonly availabilityError?: unknown;
99 readonly encryptError?: unknown;
100106 readonly decryptError?: unknown;
101107 },
102108 fileSystemLayer: Layer.Layer<FileSystem.FileSystem> = NodeServices.layer,
......@@ -120,7 +126,6 @@ function makeLayer(
120126 const safeStorageLayer = makeSafeStorageLayer({
121127 available: options?.availableSecretStorage ?? true,
122128 availabilityError: options?.availabilityError,
123 encryptError: options?.encryptError,
124129 decryptError: options?.decryptError,
125130 });
126131 const dependencies = Layer.mergeAll(
......@@ -138,7 +143,6 @@ const withSavedEnvironments = <A, E, R>(
138143 options?: {
139144 readonly availableSecretStorage?: boolean;
140145 readonly availabilityError?: unknown;
141 readonly encryptError?: unknown;
142146 readonly decryptError?: unknown;
143147 },
144148) =>
......@@ -151,20 +155,13 @@ const withSavedEnvironments = <A, E, R>(
151155 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
152156
153157describe("DesktopSavedEnvironments", () => {
154 it.effect("persists and reloads saved environment metadata", () =>
158 it.effect("reads saved environment metadata", () =>
155159 withSavedEnvironments(
156160 Effect.gen(function* () {
157 const environment = yield* DesktopEnvironment.DesktopEnvironment;
158 const fileSystem = yield* FileSystem.FileSystem;
159161 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
160 yield* savedEnvironments.setRegistry([savedRegistryRecord]);
162 yield* seedSavedEnvironmentRegistry();
161163
162164 assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]);
163 const persisted = yield* decodeSavedEnvironmentRegistryDocumentProbe(
164 yield* fileSystem.readFileString(environment.savedEnvironmentRegistryPath),
165 );
166 assert.equal(persisted.version, 1);
167 assert.lengthOf(persisted.records, 1);
168165 }),
169166 ),
170167 );
......@@ -205,17 +202,12 @@ describe("DesktopSavedEnvironments", () => {
205202 ),
206203 );
207204
208 it.effect("persists encrypted saved environment secrets when encryption is available", () =>
205 it.effect("reads encrypted saved environment secrets when encryption is available", () =>
209206 withSavedEnvironments(
210207 Effect.gen(function* () {
211208 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
212 yield* savedEnvironments.setRegistry([savedRegistryRecord]);
213
214 assert.isTrue(
215 yield* savedEnvironments.setSecret({
216 environmentId: savedRegistryRecord.environmentId,
217 secret: "bearer-token",
218 }),
209 yield* seedSavedEnvironmentRegistry(
210 Encoding.encodeBase64(textEncoder.encode("enc:bearer-token")),
219211 );
220212
221213 assert.deepEqual(
......@@ -230,14 +222,8 @@ describe("DesktopSavedEnvironments", () => {
230222 withSavedEnvironments(
231223 Effect.gen(function* () {
232224 const environment = yield* DesktopEnvironment.DesktopEnvironment;
233 const fileSystem = yield* FileSystem.FileSystem;
234225 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
235 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
236 const encoded = yield* encodeSavedEnvironmentRegistryDocumentProbe({
237 version: 1,
238 records: [{ ...savedRegistryRecord, encryptedBearerToken: "%%%" }],
239 });
240 yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, `${encoded}\n`);
226 yield* seedSavedEnvironmentRegistry("%%%");
241227
242228 const error = yield* savedEnvironments
243229 .getSecret(savedRegistryRecord.environmentId)
......@@ -256,17 +242,17 @@ describe("DesktopSavedEnvironments", () => {
256242 ),
257243 );
258244
259 it.effect("returns false when writing secrets while encryption is unavailable", () =>
245 it.effect("returns no secret while encryption is unavailable", () =>
260246 withSavedEnvironments(
261247 Effect.gen(function* () {
262248 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
263 yield* savedEnvironments.setRegistry([savedRegistryRecord]);
249 yield* seedSavedEnvironmentRegistry(
250 Encoding.encodeBase64(textEncoder.encode("enc:bearer-token")),
251 );
264252
265 assert.isFalse(
266 yield* savedEnvironments.setSecret({
267 environmentId: savedRegistryRecord.environmentId,
268 secret: "next-token",
269 }),
253 assert.deepEqual(
254 yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId),
255 Option.none(),
270256 );
271257 }),
272258 { availableSecretStorage: false },
......@@ -279,13 +265,12 @@ describe("DesktopSavedEnvironments", () => {
279265 Effect.gen(function* () {
280266 const environment = yield* DesktopEnvironment.DesktopEnvironment;
281267 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
282 yield* savedEnvironments.setRegistry([savedRegistryRecord]);
268 yield* seedSavedEnvironmentRegistry(
269 Encoding.encodeBase64(textEncoder.encode("enc:bearer-token")),
270 );
283271
284272 const error = yield* savedEnvironments
285 .setSecret({
286 environmentId: savedRegistryRecord.environmentId,
287 secret: "next-token",
288 })
273 .getSecret(savedRegistryRecord.environmentId)
289274 .pipe(Effect.flip);
290275
291276 assert.instanceOf(
......@@ -309,45 +294,6 @@ describe("DesktopSavedEnvironments", () => {
309294 );
310295 });
311296
312 it.effect("removes saved environment secrets", () =>
313 withSavedEnvironments(
314 Effect.gen(function* () {
315 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
316 yield* savedEnvironments.setRegistry([savedRegistryRecord]);
317 yield* savedEnvironments.setSecret({
318 environmentId: savedRegistryRecord.environmentId,
319 secret: "bearer-token",
320 });
321
322 yield* savedEnvironments.removeSecret(savedRegistryRecord.environmentId);
323
324 assert.isTrue(
325 Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)),
326 );
327 }),
328 ),
329 );
330
331 it.effect("removes saved environment metadata and its embedded secret atomically", () =>
332 withSavedEnvironments(
333 Effect.gen(function* () {
334 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
335 yield* savedEnvironments.setRegistry([savedRegistryRecord]);
336 yield* savedEnvironments.setSecret({
337 environmentId: savedRegistryRecord.environmentId,
338 secret: "bearer-token",
339 });
340
341 yield* savedEnvironments.removeEnvironment(savedRegistryRecord.environmentId);
342
343 assert.deepEqual(yield* savedEnvironments.getRegistry, []);
344 assert.isTrue(
345 Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)),
346 );
347 }),
348 ),
349 );
350
351297 it.effect("treats empty saved environment documents as empty", () =>
352298 withSavedEnvironments(
353299 Effect.gen(function* () {
......@@ -388,13 +334,6 @@ describe("DesktopSavedEnvironments", () => {
388334 secretError,
389335 DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError,
390336 );
391 const mutationError = yield* savedEnvironments
392 .setRegistry([savedRegistryRecord])
393 .pipe(Effect.flip);
394 assert.instanceOf(
395 mutationError,
396 DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError,
397 );
398337 }),
399338 ),
400339 );
......@@ -431,77 +370,4 @@ describe("DesktopSavedEnvironments", () => {
431370 assert.notEqual(error.message, permissionError.message);
432371 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
433372 );
434
435 it.effect("reports the failed saved environment write operation and path", () =>
436 Effect.gen(function* () {
437 const baseFileSystem = yield* FileSystem.FileSystem;
438 const path = yield* Path.Path;
439 const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({
440 prefix: "t3-desktop-saved-environments-test-",
441 });
442 const permissionError = PlatformError.systemError({
443 _tag: "PermissionDenied",
444 module: "FileSystem",
445 method: "makeDirectory",
446 pathOrDescriptor: path.join(baseDir, "userdata"),
447 });
448 const fileSystemLayer = Layer.succeed(
449 FileSystem.FileSystem,
450 FileSystem.makeNoop({
451 readFileString: baseFileSystem.readFileString,
452 makeDirectory: () => Effect.fail(permissionError),
453 }),
454 );
455 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments.pipe(
456 Effect.provide(makeLayer(baseDir, undefined, fileSystemLayer)),
457 );
458
459 const error = yield* savedEnvironments.setRegistry([savedRegistryRecord]).pipe(Effect.flip);
460 assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentsWriteError);
461 assert.equal(error.operation, "create-directory");
462 assert.equal(error.path, path.join(baseDir, "userdata"));
463 assert.strictEqual(error.cause, permissionError);
464 assert.equal(
465 error.message,
466 `Desktop saved-environment write failed during create-directory at ${path.join(baseDir, "userdata")}.`,
467 );
468 assert.notEqual(error.message, permissionError.message);
469 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
470 );
471
472 it.effect("returns false when writing a secret without metadata", () =>
473 withSavedEnvironments(
474 Effect.gen(function* () {
475 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
476
477 assert.isFalse(
478 yield* savedEnvironments.setSecret({
479 environmentId: savedRegistryRecord.environmentId,
480 secret: "bearer-token",
481 }),
482 );
483 }),
484 ),
485 );
486
487 it.effect("preserves encrypted secrets when metadata is rewritten", () =>
488 withSavedEnvironments(
489 Effect.gen(function* () {
490 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
491 yield* savedEnvironments.setRegistry([savedRegistryRecord]);
492 yield* savedEnvironments.setSecret({
493 environmentId: savedRegistryRecord.environmentId,
494 secret: "bearer-token",
495 });
496
497 yield* savedEnvironments.setRegistry([savedRegistryRecord]);
498
499 assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]);
500 assert.deepEqual(
501 yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId),
502 Option.some("bearer-token"),
503 );
504 }),
505 ),
506 );
507373});
apps/desktop/src/settings/DesktopSavedEnvironments.ts+3-270
......@@ -1,13 +1,11 @@
11import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
22import { fromLenientJson } from "@t3tools/shared/schemaJson";
33import * as Context from "effect/Context";
4import * as Crypto from "effect/Crypto";
54import * as Effect from "effect/Effect";
65import * as Encoding from "effect/Encoding";
76import * as FileSystem from "effect/FileSystem";
87import * as Layer from "effect/Layer";
98import * as Option from "effect/Option";
10import * as Path from "effect/Path";
119import * as Schema from "effect/Schema";
1210
1311import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
......@@ -65,37 +63,12 @@ const SavedEnvironmentRegistryDocumentJson = fromLenientJson(
6563const decodeSavedEnvironmentRegistryDocumentJson = Schema.decodeEffect(
6664 SavedEnvironmentRegistryDocumentJson,
6765);
68const encodeSavedEnvironmentRegistryDocumentJson = Schema.encodeEffect(
69 SavedEnvironmentRegistryDocumentJson,
70);
71
72const DesktopSavedEnvironmentsWriteOperation = Schema.Literals([
73 "create-temporary-file-name",
74 "encode-registry",
75 "create-directory",
76 "write-temporary-file",
77 "replace-registry-file",
78]);
7966
8067const DesktopSavedEnvironmentSecretProtectionOperation = Schema.Literals([
8168 "check-encryption-availability",
82 "encrypt-secret",
8369 "decrypt-secret",
8470]);
8571
86export class DesktopSavedEnvironmentsWriteError extends Schema.TaggedError<DesktopSavedEnvironmentsWriteError>()(
87 "DesktopSavedEnvironmentsWriteError",
88 {
89 operation: DesktopSavedEnvironmentsWriteOperation,
90 path: Schema.String,
91 cause: Schema.Defect(),
92 },
93) {
94 override get message(): string {
95 return `Desktop saved-environment write failed during ${this.operation} at ${this.path}.`;
96 }
97}
98
9972export class DesktopSavedEnvironmentsReadError extends Schema.TaggedError<DesktopSavedEnvironmentsReadError>()(
10073 "DesktopSavedEnvironmentsReadError",
10174 {
......@@ -152,19 +125,12 @@ export type DesktopSavedEnvironmentsReadRegistryError =
152125 | DesktopSavedEnvironmentsReadError
153126 | DesktopSavedEnvironmentsDocumentDecodeError;
154127
155export type DesktopSavedEnvironmentsMutationError =
156 | DesktopSavedEnvironmentsReadRegistryError
157 | DesktopSavedEnvironmentsWriteError;
158
159128export type DesktopSavedEnvironmentsGetSecretError =
160129 | DesktopSavedEnvironmentsReadRegistryError
161130 | DesktopSavedEnvironmentSecretDecodeError
162131 | DesktopSavedEnvironmentSecretProtectionError;
163132
164export type DesktopSavedEnvironmentsSetSecretError =
165 | DesktopSavedEnvironmentsMutationError
166 | DesktopSavedEnvironmentSecretProtectionError;
167
133/** Reads the previous registry for connection-catalog migration. */
168134export class DesktopSavedEnvironments extends Context.Service<
169135 DesktopSavedEnvironments,
170136 {
......@@ -172,22 +138,10 @@ export class DesktopSavedEnvironments extends Context.Service<
172138 readonly PersistedSavedEnvironmentRecord[],
173139 DesktopSavedEnvironmentsReadRegistryError
174140 >;
175 readonly setRegistry: (
176 records: readonly PersistedSavedEnvironmentRecord[],
177 ) => Effect.Effect<void, DesktopSavedEnvironmentsMutationError>;
178 readonly removeEnvironment: (
179 environmentId: string,
180 ) => Effect.Effect<void, DesktopSavedEnvironmentsMutationError>;
141
181142 readonly getSecret: (
182143 environmentId: string,
183144 ) => Effect.Effect<Option.Option<string>, DesktopSavedEnvironmentsGetSecretError>;
184 readonly setSecret: (input: {
185 readonly environmentId: string;
186 readonly secret: string;
187 }) => Effect.Effect<boolean, DesktopSavedEnvironmentsSetSecretError>;
188 readonly removeSecret: (
189 environmentId: string,
190 ) => Effect.Effect<void, DesktopSavedEnvironmentsMutationError>;
191145 }
192146>()("@t3tools/desktop/settings/DesktopSavedEnvironments") {}
193147
......@@ -209,28 +163,6 @@ function toPersistedSavedEnvironmentRecord(
209163 };
210164}
211165
212function toSavedEnvironmentStorageRecord(
213 record: PersistedSavedEnvironmentRecord | PersistedSavedEnvironmentStorageRecord,
214 encryptedBearerToken: Option.Option<string>,
215): PersistedSavedEnvironmentStorageRecord {
216 const nextRecord = {
217 environmentId: record.environmentId,
218 label: record.label,
219 httpBaseUrl: record.httpBaseUrl,
220 wsBaseUrl: record.wsBaseUrl,
221 createdAt: record.createdAt,
222 lastConnectedAt: record.lastConnectedAt,
223 };
224 const metadata = {
225 ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
226 ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}),
227 };
228 return Option.match(encryptedBearerToken, {
229 onNone: () => ({ ...nextRecord, ...metadata }),
230 onSome: (value) => ({ ...nextRecord, ...metadata, encryptedBearerToken: value }),
231 });
232}
233
234166function normalizeSavedEnvironmentRegistryDocument(
235167 document: SavedEnvironmentRegistryStorageDocument,
236168): SavedEnvironmentRegistryDocument {
......@@ -272,80 +204,6 @@ function readRegistryDocument(
272204 );
273205}
274206
275const writeRegistryDocument = Effect.fn("desktop.savedEnvironments.writeRegistryDocument")(
276 function* (input: {
277 readonly fileSystem: FileSystem.FileSystem;
278 readonly path: Path.Path;
279 readonly registryPath: string;
280 readonly document: SavedEnvironmentRegistryDocument;
281 readonly suffix: string;
282 }): Effect.fn.Return<void, DesktopSavedEnvironmentsWriteError> {
283 const directory = input.path.dirname(input.registryPath);
284 const tempPath = `${input.registryPath}.${process.pid}.${input.suffix}.tmp`;
285 const encoded = yield* encodeSavedEnvironmentRegistryDocumentJson(input.document).pipe(
286 Effect.mapError(
287 (cause) =>
288 new DesktopSavedEnvironmentsWriteError({
289 operation: "encode-registry",
290 path: input.registryPath,
291 cause,
292 }),
293 ),
294 );
295 yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe(
296 Effect.mapError(
297 (cause) =>
298 new DesktopSavedEnvironmentsWriteError({
299 operation: "create-directory",
300 path: directory,
301 cause,
302 }),
303 ),
304 );
305 yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe(
306 Effect.mapError(
307 (cause) =>
308 new DesktopSavedEnvironmentsWriteError({
309 operation: "write-temporary-file",
310 path: tempPath,
311 cause,
312 }),
313 ),
314 );
315 yield* input.fileSystem.rename(tempPath, input.registryPath).pipe(
316 Effect.mapError(
317 (cause) =>
318 new DesktopSavedEnvironmentsWriteError({
319 operation: "replace-registry-file",
320 path: input.registryPath,
321 cause,
322 }),
323 ),
324 );
325 },
326);
327
328function preserveExistingSecrets(
329 currentDocument: SavedEnvironmentRegistryDocument,
330 records: readonly PersistedSavedEnvironmentRecord[],
331): SavedEnvironmentRegistryDocument {
332 const encryptedBearerTokenById = new Map(
333 currentDocument.records.flatMap((record) =>
334 record.encryptedBearerToken
335 ? [[record.environmentId, record.encryptedBearerToken] as const]
336 : [],
337 ),
338 );
339
340 return {
341 version: currentDocument.version,
342 records: records.map((record) => {
343 const encryptedBearerToken = encryptedBearerTokenById.get(record.environmentId);
344 return toSavedEnvironmentStorageRecord(record, Option.fromNullishOr(encryptedBearerToken));
345 }),
346 };
347}
348
349207function decodeSecretBytes(
350208 environmentId: string,
351209 registryPath: string,
......@@ -368,31 +226,8 @@ function decodeSecretBytes(
368226export const make = Effect.gen(function* () {
369227 const environment = yield* DesktopEnvironment.DesktopEnvironment;
370228 const fileSystem = yield* FileSystem.FileSystem;
371 const path = yield* Path.Path;
372 const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
373 const crypto = yield* Crypto.Crypto;
374229
375 const writeDocument = (document: SavedEnvironmentRegistryDocument) =>
376 crypto.randomUUIDv4.pipe(
377 Effect.map((uuid) => uuid.replace(/-/g, "")),
378 Effect.mapError(
379 (cause) =>
380 new DesktopSavedEnvironmentsWriteError({
381 operation: "create-temporary-file-name",
382 path: environment.savedEnvironmentRegistryPath,
383 cause,
384 }),
385 ),
386 Effect.flatMap((suffix) =>
387 writeRegistryDocument({
388 fileSystem,
389 path,
390 registryPath: environment.savedEnvironmentRegistryPath,
391 document,
392 suffix,
393 }),
394 ),
395 );
230 const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
396231
397232 return DesktopSavedEnvironments.of({
398233 getRegistry: readRegistryDocument(fileSystem, environment.savedEnvironmentRegistryPath).pipe(
......@@ -401,30 +236,7 @@ export const make = Effect.gen(function* () {
401236 ),
402237 Effect.withSpan("desktop.savedEnvironments.getRegistry"),
403238 ),
404 setRegistry: Effect.fn("desktop.savedEnvironments.setRegistry")(function* (records) {
405 const currentDocument = yield* readRegistryDocument(
406 fileSystem,
407 environment.savedEnvironmentRegistryPath,
408 );
409 yield* writeDocument(preserveExistingSecrets(currentDocument, records));
410 }),
411 removeEnvironment: Effect.fn("desktop.savedEnvironments.removeEnvironment")(
412 function* (environmentId) {
413 yield* Effect.annotateCurrentSpan({ environmentId });
414 const document = yield* readRegistryDocument(
415 fileSystem,
416 environment.savedEnvironmentRegistryPath,
417 );
418 if (!document.records.some((record) => record.environmentId === environmentId)) {
419 return;
420 }
421239
422 yield* writeDocument({
423 version: document.version,
424 records: document.records.filter((record) => record.environmentId !== environmentId),
425 });
426 },
427 ),
428240 getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) {
429241 yield* Effect.annotateCurrentSpan({ environmentId });
430242 const document = yield* readRegistryDocument(
......@@ -472,85 +284,6 @@ export const make = Effect.gen(function* () {
472284 ),
473285 );
474286 }),
475 setSecret: Effect.fn("desktop.savedEnvironments.setSecret")(function* (input) {
476 const { environmentId, secret } = input;
477 yield* Effect.annotateCurrentSpan({ environmentId });
478 const document = yield* readRegistryDocument(
479 fileSystem,
480 environment.savedEnvironmentRegistryPath,
481 );
482
483 const encryptionAvailable = yield* safeStorage.isEncryptionAvailable.pipe(
484 Effect.mapError(
485 (cause) =>
486 new DesktopSavedEnvironmentSecretProtectionError({
487 operation: "check-encryption-availability",
488 environmentId,
489 registryPath: environment.savedEnvironmentRegistryPath,
490 cause,
491 }),
492 ),
493 );
494 if (!encryptionAvailable) {
495 return false;
496 }
497
498 const encryptedBearerToken = Encoding.encodeBase64(
499 yield* safeStorage.encryptString(secret).pipe(
500 Effect.mapError(
501 (cause) =>
502 new DesktopSavedEnvironmentSecretProtectionError({
503 operation: "encrypt-secret",
504 environmentId,
505 registryPath: environment.savedEnvironmentRegistryPath,
506 cause,
507 }),
508 ),
509 ),
510 );
511 let found = false;
512 const nextDocument: SavedEnvironmentRegistryDocument = {
513 version: document.version,
514 records: document.records.map((record) => {
515 if (record.environmentId !== environmentId) {
516 return record;
517 }
518
519 found = true;
520 return toSavedEnvironmentStorageRecord(record, Option.some(encryptedBearerToken));
521 }),
522 };
523
524 if (found) {
525 yield* writeDocument(nextDocument);
526 }
527 return found;
528 }),
529 removeSecret: Effect.fn("desktop.savedEnvironments.removeSecret")(function* (environmentId) {
530 yield* Effect.annotateCurrentSpan({ environmentId });
531 const document = yield* readRegistryDocument(
532 fileSystem,
533 environment.savedEnvironmentRegistryPath,
534 );
535 if (
536 !document.records.some(
537 (record) =>
538 record.environmentId === environmentId && record.encryptedBearerToken !== undefined,
539 )
540 ) {
541 return;
542 }
543
544 yield* writeDocument({
545 version: document.version,
546 records: document.records.map((record) => {
547 if (record.environmentId !== environmentId) {
548 return record;
549 }
550 return toPersistedSavedEnvironmentRecord(record);
551 }),
552 });
553 }),
554287 });
555288});
556289
apps/desktop/src/shell/DesktopShellEnvironment.test.ts-10
......@@ -397,16 +397,6 @@ describe("DesktopShellEnvironment", () => {
397397 }),
398398 );
399399
400 it("resolves dbus runtime dir candidates with existence checks", () => {
401 const busPath = DesktopShellEnvironment.resolveDefaultLinuxDbusSessionBusAddress({
402 env: { XDG_RUNTIME_DIR: "/tmp/stale-runtime" },
403 uid: 1000,
404 exists: (path) => path === "/run/user/1000/bus",
405 });
406
407 assert.equal(busPath, "unix:path=/run/user/1000/bus");
408 });
409
410400 it.effect("logs command failures with safe probe context and the exact cause", () => {
411401 const env: NodeJS.ProcessEnv = {
412402 SHELL: "/bin/bash",
apps/desktop/src/shell/DesktopShellEnvironment.ts-24
......@@ -122,30 +122,6 @@ const linuxRuntimeDirCandidates = (
122122 return candidates.filter((candidate) => candidate.length > 0);
123123};
124124
125function resolveDefaultLinuxDbusSessionBusPath(input: {
126 readonly env: NodeJS.ProcessEnv;
127 readonly uid: number | undefined;
128 readonly exists?: (path: string) => boolean;
129}): string | null {
130 for (const runtimeDir of linuxRuntimeDirCandidates(input.env, input.uid)) {
131 const busPath = `${runtimeDir}/bus`;
132 if (input.exists === undefined || input.exists(busPath)) {
133 return busPath;
134 }
135 }
136
137 return null;
138}
139
140export function resolveDefaultLinuxDbusSessionBusAddress(input: {
141 readonly env: NodeJS.ProcessEnv;
142 readonly exists: (path: string) => boolean;
143 readonly uid: number | undefined;
144}): string | null {
145 const busPath = resolveDefaultLinuxDbusSessionBusPath(input);
146 return busPath !== null && input.exists(busPath) ? `unix:path=${busPath}` : null;
147}
148
149125const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => {
150126 const normalized = entry.trim().replace(/^"+|"+$/g, "");
151127 return platform === "win32" ? normalized.toLowerCase() : normalized;
apps/desktop/src/ssh/DesktopSshEnvironment.ts-4
......@@ -45,10 +45,6 @@ export type DesktopSshEnvironmentOperationError =
4545
4646export type DesktopSshEnvironmentDiscoverError = SshHostDiscoveryError;
4747
48export type DesktopSshEnvironmentError =
49 | DesktopSshEnvironmentDiscoverError
50 | DesktopSshEnvironmentOperationError;
51
5248export class DesktopSshEnvironment extends Context.Service<
5349 DesktopSshEnvironment,
5450 {
apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts-4
......@@ -164,10 +164,6 @@ export type DesktopSshPasswordPromptResolveError =
164164 | DesktopSshPromptInvalidRequestIdError
165165 | DesktopSshPromptExpiredError;
166166
167export type DesktopSshPasswordPromptError =
168 | DesktopSshPasswordPromptRequestError
169 | DesktopSshPasswordPromptResolveError;
170
171167export const DesktopSshPasswordPromptCancellation = Schema.Union([
172168 DesktopSshPromptCancelledError,
173169 DesktopSshPromptWindowClosedError,
apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts+4-5
......@@ -39,7 +39,6 @@ function makeElectronAppLayer(
3939 onMetricsRead();
4040 return metrics;
4141 }),
42 isDefaultProtocolClient: () => Effect.succeed(false),
4342 setAsDefaultProtocolClient: () => Effect.succeed(true),
4443 setDesktopName: () => Effect.void,
4544 setDockIcon: () => Effect.void,
......@@ -172,7 +171,7 @@ describe("DesktopTelemetryPublisher", () => {
172171
173172 const nextSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(Effect.forkChild);
174173 yield* Effect.yieldNow;
175 yield* publisher.handleControl({
174 yield* publisher.handleControlForSource("primary-backend", {
176175 version: 1,
177176 type: "setDiagnosticsDemand",
178177 enabled: true,
......@@ -188,7 +187,7 @@ describe("DesktopTelemetryPublisher", () => {
188187 type: "setDiagnosticsDemand",
189188 enabled: true,
190189 });
191 yield* publisher.handleControl({
190 yield* publisher.handleControlForSource("primary-backend", {
192191 version: 1,
193192 type: "setDiagnosticsDemand",
194193 enabled: false,
......@@ -308,7 +307,7 @@ describe("DesktopTelemetryPublisher", () => {
308307 Effect.forkChild,
309308 );
310309 yield* Effect.yieldNow;
311 yield* publisher.handleControl({
310 yield* publisher.handleControlForSource("primary-backend", {
312311 version: 1,
313312 type: "setHostPowerIntervals",
314313 activeIntervalMs: 7_000,
......@@ -369,7 +368,7 @@ describe("DesktopTelemetryPublisher", () => {
369368 Effect.forkChild,
370369 );
371370 yield* Effect.yieldNow;
372 yield* publisher.handleControl({
371 yield* publisher.handleControlForSource("primary-backend", {
373372 version: 1,
374373 type: "setDiagnosticsDemand",
375374 enabled: true,
apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts-4
......@@ -63,7 +63,6 @@ export class DesktopTelemetryPublisher extends Context.Service<
6363 readonly latest: Effect.Effect<Option.Option<DesktopHostTelemetrySnapshot>>;
6464 readonly changes: Stream.Stream<DesktopHostTelemetrySnapshot>;
6565 readonly encoded: Stream.Stream<Uint8Array>;
66 readonly handleControl: (message: DesktopTelemetryControlMessage) => Effect.Effect<void>;
6766 readonly handleControlForSource: (
6867 sourceId: string,
6968 message: DesktopTelemetryControlMessage,
......@@ -365,8 +364,6 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {
365364 : Queue.offer(sampleTriggers, undefined).pipe(Effect.asVoid),
366365 ),
367366 );
368 const handleControl: DesktopTelemetryPublisher["Service"]["handleControl"] = (message) =>
369 handleControlForSource("legacy", message);
370367
371368 const snapshots = Stream.unwrap(
372369 Effect.gen(function* () {
......@@ -415,7 +412,6 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {
415412 latest: Ref.get(latest),
416413 changes: Stream.fromPubSub(changes),
417414 encoded,
418 handleControl,
419415 handleControlForSource,
420416 removeControlSource,
421417 publishUpdateReport,
apps/desktop/src/updates/DesktopRemoteUpdates.test.ts-1
......@@ -57,7 +57,6 @@ function runRemoteUpdatesTest(
5757 latest: Effect.succeedNone,
5858 changes: Stream.empty,
5959 encoded: Stream.empty,
60 handleControl: () => Effect.void,
6160 handleControlForSource: () => Effect.void,
6261 removeControlSource: () => Effect.void,
6362 publishUpdateReport: (report) =>
apps/desktop/src/window/DesktopApplicationMenu.test.ts-1
......@@ -41,7 +41,6 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
4141 setAboutPanelOptions: () => Effect.void,
4242 setAppUserModelId: () => Effect.void,
4343 getAppMetrics: Effect.succeed([]),
44 isDefaultProtocolClient: () => Effect.succeed(false),
4544 setAsDefaultProtocolClient: () => Effect.succeed(true),
4645 setDesktopName: () => Effect.void,
4746 setDockIcon: () => Effect.void,
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift-20
......@@ -2001,19 +2001,6 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
20012001 )
20022002 }
20032003
2004 private func fileStatusText(_ changeType: String?) -> String {
2005 switch changeType {
2006 case "new":
2007 return "A"
2008 case "deleted":
2009 return "D"
2010 case "renamed":
2011 return "R"
2012 default:
2013 return ""
2014 }
2015 }
2016
20172004 private func fileStatusColor(_ changeType: String?) -> UIColor {
20182005 switch changeType {
20192006 case "new":
......@@ -2027,13 +2014,6 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
20272014 }
20282015 }
20292016
2030 private func drawStatusPill(_ text: String, rect: CGRect, color: UIColor, font: UIFont) {
2031 let path = UIBezierPath(roundedRect: rect, cornerRadius: rect.height / 2)
2032 color.withAlphaComponent(0.12).setFill()
2033 path.fill()
2034 drawCenteredText(text, rect: rect, color: color, font: font)
2035 }
2036
20372017 private func drawFileIcon(rect: CGRect, changeType: String?) {
20382018 let color = fileStatusColor(changeType)
20392019 let outerPath = UIBezierPath(roundedRect: rect, cornerRadius: 6)
apps/mobile/src/features/cloud/linkEnvironment.test.ts+14-883
......@@ -4,20 +4,13 @@ import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
55import { EnvironmentId } from "@t3tools/contracts";
66import { RelayMobileClientId } from "@t3tools/contracts/relay";
7import { DPOP_UNKNOWN_HINT, ManagedRelay } from "@t3tools/client-runtime/relay";
7import { ManagedRelay } from "@t3tools/client-runtime/relay";
88import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";
99import { HttpClient } from "effect/unstable/http";
10import { MobilePreferencesStore } from "../../persistence/mobile-preferences";
10
1111import { MobileStorage } from "../../persistence/mobile-storage";
1212
13import {
14 linkEnvironmentToCloud,
15 linkEnvironmentToCloudWithPreference,
16 connectCloudEnvironment,
17 listCloudEnvironments,
18 listCloudEnvironmentsWithStatus,
19 refreshCloudEnvironmentConnection,
20} from "./linkEnvironment";
13import { linkEnvironmentToCloudWithPreference } from "./linkEnvironment";
2114
2215vi.mock("expo-constants", () => ({
2316 default: {
......@@ -56,8 +49,6 @@ vi.mock("expo-secure-store", () => ({
5649 setItemAsync: vi.fn(),
5750}));
5851
59const loadPreferences = vi.fn(() => Effect.succeed({}));
60
6152const savedConnection = {
6253 environmentId: EnvironmentId.make("env-1"),
6354 environmentLabel: "Desktop",
......@@ -68,8 +59,6 @@ const savedConnection = {
6859 bearerToken: "local-bearer",
6960};
7061
71const stableClerkToken = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyXzEyMyJ9.test";
72
7362const createProofMock = vi.fn(
7463 (input: { readonly method: string; readonly url: string; readonly accessToken?: string }) =>
7564 Effect.succeed(`dpop:${input.method}:${input.url}`),
......@@ -86,14 +75,6 @@ function cloudClientLayer() {
8675 const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init));
8776 return Layer.mergeAll(
8877 httpClientLayer,
89 Layer.succeed(
90 MobilePreferencesStore,
91 MobilePreferencesStore.of({
92 load: loadPreferences(),
93 savePatch: (patch) => Effect.succeed(patch),
94 update: () => Effect.succeed({}),
95 }),
96 ),
9778 Layer.succeed(
9879 MobileStorage,
9980 MobileStorage.of({
......@@ -123,7 +104,6 @@ const withCloudServices = <A, E>(
123104 | HttpClient.HttpClient
124105 | ManagedRelay.ManagedRelayClient
125106 | ManagedRelay.ManagedRelayDpopSigner
126 | MobilePreferencesStore
127107 | MobileStorage
128108 >,
129109) => effect.pipe(Effect.provide(cloudClientLayer()));
......@@ -163,439 +143,12 @@ function requestBodyText(body: BodyInit | null | undefined): string {
163143 return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? "");
164144}
165145
166function validDpopAccessTokenResponse(scope = "environment:status environment:connect") {
167 return {
168 access_token: "relay-dpop-token",
169 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
170 token_type: "DPoP",
171 expires_in: 300,
172 scope,
173 };
174}
175
176function listedEnvironment(environmentId: string) {
177 return {
178 environmentId: EnvironmentId.make(environmentId),
179 label: "Desktop",
180 endpoint: {
181 httpBaseUrl: `https://${environmentId}.example.test/`,
182 wsBaseUrl: `wss://${environmentId}.example.test/ws`,
183 providerKind: "cloudflare_tunnel" as const,
184 },
185 linkedAt: "2026-05-25T00:00:00.000Z",
186 };
187}
188
189146describe("mobile cloud link environment client", () => {
190147 beforeEach(() => {
191148 vi.restoreAllMocks();
192149 createProofMock.mockClear();
193 loadPreferences.mockClear();
194150 });
195151
196 it.effect("decodes relay environment list responses before returning records", () =>
197 Effect.gen(function* () {
198 vi.stubGlobal(
199 "fetch",
200 vi.fn(() =>
201 Promise.resolve(
202 Response.json({
203 environments: [
204 {
205 environmentId: "env-1",
206 label: "Desktop",
207 endpoint: {
208 httpBaseUrl: "https://desktop.example.test/",
209 wsBaseUrl: "wss://desktop.example.test/ws",
210 providerKind: "cloudflare_tunnel",
211 },
212 linkedAt: "2026-05-25T00:00:00.000Z",
213 },
214 ],
215 }),
216 ),
217 ),
218 );
219
220 const records = yield* withCloudServices(
221 listCloudEnvironments({ clerkToken: "clerk-token" }),
222 );
223 expect(records).toEqual([
224 {
225 environmentId: "env-1",
226 label: "Desktop",
227 endpoint: {
228 httpBaseUrl: "https://desktop.example.test/",
229 wsBaseUrl: "wss://desktop.example.test/ws",
230 providerKind: "cloudflare_tunnel",
231 },
232 linkedAt: "2026-05-25T00:00:00.000Z",
233 },
234 ]);
235 }),
236 );
237
238 it.effect("rejects malformed relay environment list responses", () =>
239 Effect.gen(function* () {
240 vi.stubGlobal(
241 "fetch",
242 vi.fn(() =>
243 Promise.resolve(
244 Response.json({
245 environments: [
246 {
247 environmentId: "env-1",
248 label: "Desktop",
249 endpoint: {
250 httpBaseUrl: "",
251 wsBaseUrl: "wss://desktop.example.test/ws",
252 providerKind: "cloudflare_tunnel",
253 },
254 linkedAt: "2026-05-25T00:00:00.000Z",
255 },
256 ],
257 }),
258 ),
259 ),
260 );
261
262 const error = yield* withCloudServices(
263 listCloudEnvironments({ clerkToken: "clerk-token" }),
264 ).pipe(Effect.flip);
265 expect(error).toMatchObject({
266 _tag: "CloudEnvironmentLinkError",
267 message: "https://relay.example.test/v1/environments failed",
268 });
269 }),
270 );
271
272 it.effect("loads signed status for each advertised cloud environment", () =>
273 Effect.gen(function* () {
274 const fetchMock = vi.fn((url: string | URL, _init?: RequestInit) => {
275 if (String(url) === "https://relay.example.test/v1/environments") {
276 return Promise.resolve(
277 Response.json({
278 environments: [
279 {
280 environmentId: "env-1",
281 label: "Desktop",
282 endpoint: {
283 httpBaseUrl: "https://desktop.example.test/",
284 wsBaseUrl: "wss://desktop.example.test/ws",
285 providerKind: "cloudflare_tunnel",
286 },
287 linkedAt: "2026-05-25T00:00:00.000Z",
288 },
289 ],
290 }),
291 );
292 }
293 if (String(url) === "https://relay.example.test/v1/client/dpop-token") {
294 return Promise.resolve(Response.json(validDpopAccessTokenResponse()));
295 }
296 expect(String(url)).toBe("https://relay.example.test/v1/environments/env-1/status");
297 return Promise.resolve(
298 Response.json({
299 environmentId: "env-1",
300 endpoint: {
301 httpBaseUrl: "https://desktop.example.test/",
302 wsBaseUrl: "wss://desktop.example.test/ws",
303 providerKind: "cloudflare_tunnel",
304 },
305 status: "online",
306 checkedAt: "2026-05-25T00:01:00.000Z",
307 descriptor: {
308 environmentId: "env-1",
309 label: "Desktop",
310 platform: { os: "darwin", arch: "arm64" },
311 serverVersion: "0.0.0-test",
312 capabilities: { repositoryIdentity: true },
313 },
314 }),
315 );
316 });
317 vi.stubGlobal("fetch", fetchMock);
318
319 const records = yield* withCloudServices(
320 listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }),
321 );
322 expect(records).toMatchObject([
323 {
324 environment: {
325 environmentId: "env-1",
326 label: "Desktop",
327 },
328 status: {
329 environmentId: "env-1",
330 status: "online",
331 checkedAt: "2026-05-25T00:01:00.000Z",
332 },
333 statusError: null,
334 },
335 ]);
336 expect(String(fetchMock.mock.calls[2]?.[0])).toBe(
337 "https://relay.example.test/v1/environments/env-1/status",
338 );
339 expect(fetchMock.mock.calls[2]?.[1]?.method).toBe("POST");
340 const statusHeaders = new Headers(fetchMock.mock.calls[2]?.[1]?.headers);
341 expect(statusHeaders.get("authorization")).toBe("DPoP relay-dpop-token");
342 expect(statusHeaders.get("dpop")).toBe(
343 "dpop:POST:https://relay.example.test/v1/environments/env-1/status",
344 );
345 expect(createProofMock).toHaveBeenCalledWith({
346 method: "POST",
347 url: "https://relay.example.test/v1/environments/env-1/status",
348 accessToken: "relay-dpop-token",
349 });
350 }),
351 );
352
353 it.effect("reuses one valid DPoP access token while probing multiple environment statuses", () =>
354 Effect.gen(function* () {
355 const fetchMock = vi.fn((url: string | URL, _init?: RequestInit) => {
356 if (String(url).endsWith("/v1/environments")) {
357 return Promise.resolve(
358 Response.json({
359 environments: [listedEnvironment("env-1"), listedEnvironment("env-2")],
360 }),
361 );
362 }
363 if (String(url).endsWith("/v1/client/dpop-token")) {
364 return Promise.resolve(Response.json(validDpopAccessTokenResponse()));
365 }
366 const environmentId = String(url).includes("/env-1/") ? "env-1" : "env-2";
367 return Promise.resolve(
368 Response.json({
369 environmentId,
370 endpoint: listedEnvironment(environmentId).endpoint,
371 status: "online",
372 checkedAt: "2026-05-25T00:01:00.000Z",
373 descriptor: {
374 environmentId,
375 label: "Desktop",
376 platform: { os: "darwin", arch: "arm64" },
377 serverVersion: "0.0.0-test",
378 capabilities: { repositoryIdentity: true },
379 },
380 }),
381 );
382 });
383 vi.stubGlobal("fetch", fetchMock);
384
385 yield* withCloudServices(listCloudEnvironmentsWithStatus({ clerkToken: stableClerkToken }));
386
387 expect(
388 fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/v1/client/dpop-token")),
389 ).toHaveLength(1);
390 }),
391 );
392
393 it.effect("reuses the status-and-connect token when connecting from the cloud list", () =>
394 Effect.gen(function* () {
395 const fetchMock = vi.fn((url: string | URL, _init?: RequestInit) => {
396 if (String(url).endsWith("/v1/environments")) {
397 return Promise.resolve(
398 Response.json({
399 environments: [listedEnvironment("env-1")],
400 }),
401 );
402 }
403 if (String(url).endsWith("/v1/client/dpop-token")) {
404 return Promise.resolve(Response.json(validDpopAccessTokenResponse()));
405 }
406 if (String(url).endsWith("/status")) {
407 return Promise.resolve(
408 Response.json({
409 environmentId: "env-1",
410 endpoint: listedEnvironment("env-1").endpoint,
411 status: "online",
412 checkedAt: "2026-05-25T00:01:00.000Z",
413 descriptor: {
414 environmentId: "env-1",
415 label: "Desktop",
416 platform: { os: "darwin", arch: "arm64" },
417 serverVersion: "0.0.0-test",
418 capabilities: { repositoryIdentity: true },
419 },
420 }),
421 );
422 }
423 if (String(url).endsWith("/connect")) {
424 return Promise.resolve(
425 Response.json({
426 environmentId: "env-1",
427 endpoint: listedEnvironment("env-1").endpoint,
428 credential: "one-time-cloud-credential",
429 expiresAt: "2026-05-25T00:05:00.000Z",
430 }),
431 );
432 }
433 if (String(url).endsWith("/.well-known/t3/environment")) {
434 return Promise.resolve(
435 Response.json({
436 environmentId: "env-1",
437 label: "Desktop",
438 platform: { os: "darwin", arch: "arm64" },
439 serverVersion: "0.0.0-test",
440 capabilities: { repositoryIdentity: true },
441 }),
442 );
443 }
444 return Promise.resolve(
445 Response.json({
446 access_token: "environment-dpop-token",
447 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
448 token_type: "DPoP",
449 expires_in: 3600,
450 scope: "orchestration:read orchestration:operate terminal:operate review:write",
451 }),
452 );
453 });
454 vi.stubGlobal("fetch", fetchMock);
455
456 yield* withCloudServices(
457 Effect.gen(function* () {
458 const records = yield* listCloudEnvironmentsWithStatus({
459 clerkToken: stableClerkToken,
460 });
461 yield* connectCloudEnvironment({
462 clerkToken: stableClerkToken,
463 environment: records[0]!.environment,
464 });
465 }),
466 );
467
468 expect(
469 fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/v1/client/dpop-token")),
470 ).toHaveLength(1);
471 const exchangeRequest = fetchMock.mock.calls.find(([url]) =>
472 String(url).endsWith("/v1/client/dpop-token"),
473 )?.[1];
474 expect(new URLSearchParams(requestBodyText(exchangeRequest?.body)).get("scope")).toBe(
475 "environment:status environment:connect",
476 );
477 const environmentTokenRequest = fetchMock.mock.calls.find(([url]) =>
478 String(url).endsWith("/oauth/token"),
479 )?.[1];
480 const environmentTokenBody = new URLSearchParams(
481 requestBodyText(environmentTokenRequest?.body),
482 );
483 expect(environmentTokenBody.get("client_label")).toBe("T3 Code Mobile");
484 expect(environmentTokenBody.get("client_device_type")).toBe("mobile");
485 expect(environmentTokenBody.get("client_os")).toBe("iOS");
486 }),
487 );
488
489 it.effect("keeps advertised environments visible when status probing fails", () =>
490 Effect.gen(function* () {
491 vi.stubGlobal(
492 "fetch",
493 vi.fn((url: string | URL) => {
494 if (String(url) === "https://relay.example.test/v1/environments") {
495 return Promise.resolve(
496 Response.json({
497 environments: [
498 {
499 environmentId: "env-1",
500 label: "Desktop",
501 endpoint: {
502 httpBaseUrl: "https://desktop.example.test/",
503 wsBaseUrl: "wss://desktop.example.test/ws",
504 providerKind: "cloudflare_tunnel",
505 },
506 linkedAt: "2026-05-25T00:00:00.000Z",
507 },
508 ],
509 }),
510 );
511 }
512 if (String(url) === "https://relay.example.test/v1/client/dpop-token") {
513 return Promise.resolve(Response.json(validDpopAccessTokenResponse()));
514 }
515 return Promise.resolve(Response.json({ error: "offline" }, { status: 503 }));
516 }),
517 );
518
519 const records = yield* withCloudServices(
520 listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }),
521 );
522 expect(records).toMatchObject([
523 {
524 environment: {
525 environmentId: "env-1",
526 label: "Desktop",
527 },
528 status: null,
529 statusError: "https://relay.example.test/v1/environments/env-1/status failed",
530 },
531 ]);
532 }),
533 );
534
535 it.effect("rejects status responses for a different advertised environment", () =>
536 Effect.gen(function* () {
537 vi.stubGlobal(
538 "fetch",
539 vi.fn((url: string | URL) => {
540 if (String(url) === "https://relay.example.test/v1/environments") {
541 return Promise.resolve(
542 Response.json({
543 environments: [
544 {
545 environmentId: "env-1",
546 label: "Desktop",
547 endpoint: {
548 httpBaseUrl: "https://desktop.example.test/",
549 wsBaseUrl: "wss://desktop.example.test/ws",
550 providerKind: "cloudflare_tunnel",
551 },
552 linkedAt: "2026-05-25T00:00:00.000Z",
553 },
554 ],
555 }),
556 );
557 }
558 if (String(url) === "https://relay.example.test/v1/client/dpop-token") {
559 return Promise.resolve(Response.json(validDpopAccessTokenResponse()));
560 }
561 return Promise.resolve(
562 Response.json({
563 environmentId: "env-other",
564 endpoint: {
565 httpBaseUrl: "https://desktop.example.test/",
566 wsBaseUrl: "wss://desktop.example.test/ws",
567 providerKind: "cloudflare_tunnel",
568 },
569 status: "online",
570 checkedAt: "2026-05-25T00:01:00.000Z",
571 descriptor: {
572 environmentId: "env-other",
573 label: "Other Desktop",
574 platform: { os: "darwin", arch: "arm64" },
575 serverVersion: "0.0.0-test",
576 capabilities: { repositoryIdentity: true },
577 },
578 }),
579 );
580 }),
581 );
582
583 const records = yield* withCloudServices(
584 listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }),
585 );
586 expect(records).toMatchObject([
587 {
588 environment: {
589 environmentId: "env-1",
590 label: "Desktop",
591 },
592 status: null,
593 statusError: "Relay returned status for a different environment.",
594 },
595 ]);
596 }),
597 );
598
599152 it.effect(
600153 "rejects relay link credentials for a different environment before persisting relay config",
601154 () =>
......@@ -612,9 +165,10 @@ describe("mobile cloud link environment client", () => {
612165 vi.stubGlobal("fetch", fetchMock);
613166
614167 const error = yield* withCloudServices(
615 linkEnvironmentToCloud({
168 linkEnvironmentToCloudWithPreference({
616169 clerkToken: "clerk-token",
617170 connection: savedConnection,
171 liveActivitiesEnabled: true,
618172 }),
619173 ).pipe(Effect.flip);
620174 expect(error).toMatchObject({
......@@ -644,9 +198,10 @@ describe("mobile cloud link environment client", () => {
644198 vi.stubGlobal("fetch", fetchMock);
645199
646200 const error = yield* withCloudServices(
647 linkEnvironmentToCloud({
201 linkEnvironmentToCloudWithPreference({
648202 clerkToken: "clerk-token",
649203 connection: savedConnection,
204 liveActivitiesEnabled: true,
650205 }),
651206 ).pipe(Effect.flip);
652207 expect(error._tag).toBe("CloudEnvironmentLinkError");
......@@ -681,9 +236,10 @@ describe("mobile cloud link environment client", () => {
681236 vi.stubGlobal("fetch", fetchMock);
682237
683238 const error = yield* withCloudServices(
684 linkEnvironmentToCloud({
239 linkEnvironmentToCloudWithPreference({
685240 clerkToken: "clerk-token",
686241 connection: savedConnection,
242 liveActivitiesEnabled: true,
687243 }),
688244 ).pipe(Effect.flip);
689245 expect(error).toMatchObject({
......@@ -718,9 +274,10 @@ describe("mobile cloud link environment client", () => {
718274 vi.stubGlobal("fetch", fetchMock);
719275
720276 const error = yield* withCloudServices(
721 linkEnvironmentToCloud({
277 linkEnvironmentToCloudWithPreference({
722278 clerkToken: "clerk-token",
723279 connection: savedConnection,
280 liveActivitiesEnabled: true,
724281 }),
725282 ).pipe(Effect.flip);
726283 expect(error).toMatchObject({
......@@ -733,7 +290,6 @@ describe("mobile cloud link environment client", () => {
733290
734291 it.effect("preserves disabled Live Activity preferences when linking an environment", () =>
735292 Effect.gen(function* () {
736 loadPreferences.mockReturnValueOnce(Effect.succeed({ liveActivitiesEnabled: false }));
737293 const bodies: Array<unknown> = [];
738294 const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => {
739295 if (init?.body) {
......@@ -756,9 +312,10 @@ describe("mobile cloud link environment client", () => {
756312 vi.stubGlobal("fetch", fetchMock);
757313
758314 yield* withCloudServices(
759 linkEnvironmentToCloud({
315 linkEnvironmentToCloudWithPreference({
760316 clerkToken: "clerk-token",
761317 connection: savedConnection,
318 liveActivitiesEnabled: false,
762319 }),
763320 );
764321
......@@ -786,9 +343,8 @@ describe("mobile cloud link environment client", () => {
786343 }),
787344 );
788345
789 it.effect("uses an explicit Live Activity preference when persisted state is unavailable", () =>
346 it.effect("enables Live Activities for both the link challenge and registration", () =>
790347 Effect.gen(function* () {
791 loadPreferences.mockReturnValueOnce(Effect.die("persisted preferences must not be read"));
792348 const bodies: Array<Record<string, unknown>> = [];
793349 const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => {
794350 if (init?.body) {
......@@ -824,429 +380,4 @@ describe("mobile cloud link environment client", () => {
824380 ]);
825381 }),
826382 );
827
828 it.effect(
829 "does not persist cloud connect bootstrap credentials in saved connection records",
830 () =>
831 Effect.gen(function* () {
832 let connectRequestBody = "";
833 const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => {
834 if (String(url).endsWith("/v1/client/dpop-token")) {
835 return Promise.resolve(
836 Response.json(validDpopAccessTokenResponse("environment:connect")),
837 );
838 }
839 if (String(url).endsWith("/.well-known/t3/environment")) {
840 return Promise.resolve(
841 Response.json({
842 environmentId: "env-1",
843 label: "Desktop",
844 platform: { os: "darwin", arch: "arm64" },
845 serverVersion: "0.0.0-test",
846 capabilities: { repositoryIdentity: true },
847 }),
848 );
849 }
850 if (String(url).endsWith("/oauth/token")) {
851 return Promise.resolve(
852 Response.json({
853 access_token: "environment-dpop-token",
854 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
855 token_type: "DPoP",
856 expires_in: 3600,
857 scope: "orchestration:read orchestration:operate terminal:operate review:write",
858 }),
859 );
860 }
861 connectRequestBody = requestBodyText(init?.body);
862 return Promise.resolve(
863 Response.json({
864 environmentId: "env-1",
865 endpoint: {
866 httpBaseUrl: "https://desktop.example.test/",
867 wsBaseUrl: "wss://desktop.example.test/ws",
868 providerKind: "cloudflare_tunnel",
869 },
870 credential: "one-time-cloud-credential",
871 expiresAt: "2026-05-25T00:05:00.000Z",
872 }),
873 );
874 });
875 vi.stubGlobal("fetch", fetchMock);
876
877 const connection = yield* withCloudServices(
878 connectCloudEnvironment({
879 clerkToken: "clerk-token",
880 environment: {
881 environmentId: EnvironmentId.make("env-1"),
882 label: "Desktop",
883 endpoint: {
884 httpBaseUrl: "https://desktop.example.test/",
885 wsBaseUrl: "wss://desktop.example.test/ws",
886 providerKind: "cloudflare_tunnel",
887 },
888 linkedAt: "2026-05-25T00:00:00.000Z",
889 },
890 }),
891 );
892
893 expect(connection.pairingUrl).toBe("https://desktop.example.test/");
894 expect(connection.pairingUrl).not.toContain("one-time-cloud-credential");
895 expect(connection.bearerToken).toBeNull();
896 expect(connection.authenticationMethod).toBe("dpop");
897 expect(connection.dpopAccessToken).toBe("environment-dpop-token");
898 expect(connection.relayManaged).toBe(true);
899 // @effect-diagnostics-next-line preferSchemaOverJson:off
900 expect(JSON.parse(connectRequestBody)).toMatchObject({
901 deviceId: "device-1",
902 clientKeyThumbprint: "client-proof-key-thumbprint",
903 });
904 expect(createProofMock).toHaveBeenCalledWith({
905 method: "POST",
906 url: "https://relay.example.test/v1/environments/env-1/connect",
907 accessToken: "relay-dpop-token",
908 });
909 expect(createProofMock).toHaveBeenCalledWith({
910 method: "POST",
911 url: "https://desktop.example.test/oauth/token",
912 });
913 }),
914 );
915
916 it.effect("refreshes a saved environment against a rotated managed endpoint", () =>
917 Effect.gen(function* () {
918 vi.stubGlobal(
919 "fetch",
920 vi.fn((url: string | URL) => {
921 if (String(url).endsWith("/v1/client/dpop-token")) {
922 return Promise.resolve(
923 Response.json(validDpopAccessTokenResponse("environment:connect")),
924 );
925 }
926 if (String(url).endsWith("/.well-known/t3/environment")) {
927 return Promise.resolve(
928 Response.json({
929 environmentId: "env-1",
930 label: "Rotated Desktop",
931 platform: { os: "darwin", arch: "arm64" },
932 serverVersion: "0.0.0-test",
933 capabilities: { repositoryIdentity: true },
934 }),
935 );
936 }
937 if (String(url).endsWith("/oauth/token")) {
938 return Promise.resolve(
939 Response.json({
940 access_token: "fresh-environment-dpop-token",
941 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
942 token_type: "DPoP",
943 expires_in: 3600,
944 scope: "orchestration:read orchestration:operate terminal:operate review:write",
945 }),
946 );
947 }
948 return Promise.resolve(
949 Response.json({
950 environmentId: "env-1",
951 endpoint: {
952 httpBaseUrl: "https://rotated-desktop.example.test/",
953 wsBaseUrl: "wss://rotated-desktop.example.test/ws",
954 providerKind: "cloudflare_tunnel",
955 },
956 credential: "rotated-one-time-cloud-credential",
957 expiresAt: "2026-05-25T00:05:00.000Z",
958 }),
959 );
960 }),
961 );
962
963 const connection = yield* withCloudServices(
964 refreshCloudEnvironmentConnection({
965 clerkToken: "clerk-token",
966 connection: {
967 environmentId: EnvironmentId.make("env-1"),
968 environmentLabel: "Desktop",
969 pairingUrl: "https://desktop.example.test/",
970 displayUrl: "https://desktop.example.test/",
971 httpBaseUrl: "https://desktop.example.test/",
972 wsBaseUrl: "wss://desktop.example.test/ws",
973 bearerToken: null,
974 authenticationMethod: "dpop",
975 relayManaged: true,
976 },
977 }),
978 );
979
980 expect(connection).toMatchObject({
981 environmentId: "env-1",
982 environmentLabel: "Rotated Desktop",
983 displayUrl: "https://rotated-desktop.example.test/",
984 httpBaseUrl: "https://rotated-desktop.example.test/",
985 wsBaseUrl: "wss://rotated-desktop.example.test/ws",
986 dpopAccessToken: "fresh-environment-dpop-token",
987 });
988 }),
989 );
990
991 it.effect("rejects relay connect responses for a different environment", () =>
992 Effect.gen(function* () {
993 vi.stubGlobal(
994 "fetch",
995 vi.fn((url: string | URL) =>
996 Promise.resolve(
997 String(url).endsWith("/v1/client/dpop-token")
998 ? Response.json(validDpopAccessTokenResponse("environment:connect"))
999 : Response.json({
1000 environmentId: "env-other",
1001 endpoint: {
1002 httpBaseUrl: "https://desktop.example.test/",
1003 wsBaseUrl: "wss://desktop.example.test/ws",
1004 providerKind: "cloudflare_tunnel",
1005 },
1006 credential: "one-time-cloud-credential",
1007 expiresAt: "2026-05-25T00:05:00.000Z",
1008 }),
1009 ),
1010 ),
1011 );
1012
1013 const error = yield* withCloudServices(
1014 connectCloudEnvironment({
1015 clerkToken: "clerk-token",
1016 environment: {
1017 environmentId: EnvironmentId.make("env-1"),
1018 label: "Desktop",
1019 endpoint: {
1020 httpBaseUrl: "https://desktop.example.test/",
1021 wsBaseUrl: "wss://desktop.example.test/ws",
1022 providerKind: "cloudflare_tunnel",
1023 },
1024 linkedAt: "2026-05-25T00:00:00.000Z",
1025 },
1026 }),
1027 ).pipe(Effect.flip);
1028 expect(error).toMatchObject({
1029 _tag: "CloudEnvironmentLinkError",
1030 message: "Relay returned credentials for a different environment.",
1031 });
1032 }),
1033 );
1034
1035 it.effect("preserves relay DPoP auth failures while connecting environments", () =>
1036 Effect.gen(function* () {
1037 vi.stubGlobal(
1038 "fetch",
1039 vi.fn((url: string | URL) =>
1040 Promise.resolve(
1041 String(url).endsWith("/v1/client/dpop-token")
1042 ? Response.json(validDpopAccessTokenResponse("environment:connect"))
1043 : Response.json(
1044 {
1045 _tag: "RelayAuthInvalidError",
1046 code: "auth_invalid",
1047 reason: "invalid_dpop",
1048 traceId: "trace-connect",
1049 },
1050 { status: 401 },
1051 ),
1052 ),
1053 ),
1054 );
1055
1056 const error = yield* withCloudServices(
1057 connectCloudEnvironment({
1058 clerkToken: "clerk-token",
1059 environment: {
1060 environmentId: EnvironmentId.make("env-1"),
1061 label: "Desktop",
1062 endpoint: {
1063 httpBaseUrl: "https://desktop.example.test/",
1064 wsBaseUrl: "wss://desktop.example.test/ws",
1065 providerKind: "cloudflare_tunnel",
1066 },
1067 linkedAt: "2026-05-25T00:00:00.000Z",
1068 },
1069 }),
1070 ).pipe(Effect.flip);
1071 expect(error).toMatchObject({
1072 _tag: "CloudEnvironmentLinkError",
1073 message: `https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof. ${DPOP_UNKNOWN_HINT}`,
1074 traceId: "trace-connect",
1075 });
1076 }),
1077 );
1078
1079 it.effect(
1080 "presents clock skew as one possible cause when an older environment rejects DPoP",
1081 () =>
1082 Effect.gen(function* () {
1083 vi.stubGlobal(
1084 "fetch",
1085 vi.fn((url: string | URL) => {
1086 const value = String(url);
1087 if (value.endsWith("/v1/client/dpop-token")) {
1088 return Promise.resolve(
1089 Response.json(validDpopAccessTokenResponse("environment:connect")),
1090 );
1091 }
1092 if (value.endsWith("/v1/environments/env-1/connect")) {
1093 return Promise.resolve(
1094 Response.json({
1095 environmentId: "env-1",
1096 endpoint: {
1097 httpBaseUrl: "https://desktop.example.test/",
1098 wsBaseUrl: "wss://desktop.example.test/ws",
1099 providerKind: "cloudflare_tunnel",
1100 },
1101 credential: "one-time-cloud-credential",
1102 expiresAt: "2026-05-25T00:05:00.000Z",
1103 }),
1104 );
1105 }
1106 if (value.endsWith("/.well-known/t3/environment")) {
1107 return Promise.resolve(
1108 Response.json({
1109 environmentId: "env-1",
1110 label: "Desktop",
1111 platform: { os: "darwin", arch: "arm64" },
1112 serverVersion: "0.0.0-test",
1113 capabilities: { repositoryIdentity: true },
1114 }),
1115 );
1116 }
1117 return Promise.resolve(
1118 Response.json(
1119 {
1120 _tag: "EnvironmentAuthInvalidError",
1121 code: "auth_invalid",
1122 reason: "invalid_credential",
1123 traceId: "trace-environment",
1124 },
1125 { status: 401 },
1126 ),
1127 );
1128 }),
1129 );
1130
1131 const error = yield* withCloudServices(
1132 connectCloudEnvironment({
1133 clerkToken: "clerk-token",
1134 environment: {
1135 environmentId: EnvironmentId.make("env-1"),
1136 label: "Desktop",
1137 endpoint: {
1138 httpBaseUrl: "https://desktop.example.test/",
1139 wsBaseUrl: "wss://desktop.example.test/ws",
1140 providerKind: "cloudflare_tunnel",
1141 },
1142 linkedAt: "2026-05-25T00:00:00.000Z",
1143 },
1144 }),
1145 ).pipe(Effect.flip);
1146
1147 expect(error).toMatchObject({
1148 _tag: "CloudEnvironmentLinkError",
1149 message: `Could not exchange a managed endpoint DPoP access token. ${DPOP_UNKNOWN_HINT}`,
1150 traceId: "trace-environment",
1151 });
1152 }),
1153 );
1154
1155 it.effect("rejects relay connect responses for a different endpoint", () =>
1156 Effect.gen(function* () {
1157 vi.stubGlobal(
1158 "fetch",
1159 vi.fn((url: string | URL) =>
1160 Promise.resolve(
1161 String(url).endsWith("/v1/client/dpop-token")
1162 ? Response.json(validDpopAccessTokenResponse("environment:connect"))
1163 : Response.json({
1164 environmentId: "env-1",
1165 endpoint: {
1166 httpBaseUrl: "https://other-desktop.example.test/",
1167 wsBaseUrl: "wss://other-desktop.example.test/ws",
1168 providerKind: "cloudflare_tunnel",
1169 },
1170 credential: "one-time-cloud-credential",
1171 expiresAt: "2026-05-25T00:05:00.000Z",
1172 }),
1173 ),
1174 ),
1175 );
1176
1177 const error = yield* withCloudServices(
1178 connectCloudEnvironment({
1179 clerkToken: "clerk-token",
1180 environment: {
1181 environmentId: EnvironmentId.make("env-1"),
1182 label: "Desktop",
1183 endpoint: {
1184 httpBaseUrl: "https://desktop.example.test/",
1185 wsBaseUrl: "wss://desktop.example.test/ws",
1186 providerKind: "cloudflare_tunnel",
1187 },
1188 linkedAt: "2026-05-25T00:00:00.000Z",
1189 },
1190 }),
1191 ).pipe(Effect.flip);
1192 expect(error).toMatchObject({
1193 _tag: "CloudEnvironmentLinkError",
1194 message: "Relay returned credentials for a different endpoint.",
1195 });
1196 }),
1197 );
1198
1199 it.effect(
1200 "rejects managed endpoints whose descriptor does not match the selected environment",
1201 () =>
1202 Effect.gen(function* () {
1203 vi.stubGlobal(
1204 "fetch",
1205 vi.fn((url: string | URL) =>
1206 Promise.resolve(
1207 String(url).endsWith("/v1/client/dpop-token")
1208 ? Response.json(validDpopAccessTokenResponse("environment:connect"))
1209 : String(url).endsWith("/.well-known/t3/environment")
1210 ? Response.json({
1211 environmentId: "env-other",
1212 label: "Other Desktop",
1213 platform: { os: "darwin", arch: "arm64" },
1214 serverVersion: "0.0.0-test",
1215 capabilities: { repositoryIdentity: true },
1216 })
1217 : Response.json({
1218 environmentId: "env-1",
1219 endpoint: {
1220 httpBaseUrl: "https://desktop.example.test/",
1221 wsBaseUrl: "wss://desktop.example.test/ws",
1222 providerKind: "cloudflare_tunnel",
1223 },
1224 credential: "one-time-cloud-credential",
1225 expiresAt: "2026-05-25T00:05:00.000Z",
1226 }),
1227 ),
1228 ),
1229 );
1230
1231 const error = yield* withCloudServices(
1232 connectCloudEnvironment({
1233 clerkToken: "clerk-token",
1234 environment: {
1235 environmentId: EnvironmentId.make("env-1"),
1236 label: "Desktop",
1237 endpoint: {
1238 httpBaseUrl: "https://desktop.example.test/",
1239 wsBaseUrl: "wss://desktop.example.test/ws",
1240 providerKind: "cloudflare_tunnel",
1241 },
1242 linkedAt: "2026-05-25T00:00:00.000Z",
1243 },
1244 }),
1245 ).pipe(Effect.flip);
1246 expect(error).toMatchObject({
1247 _tag: "CloudEnvironmentLinkError",
1248 message: "Connected endpoint descriptor does not match the selected environment.",
1249 });
1250 }),
1251 );
1252383});
apps/mobile/src/features/cloud/linkEnvironment.ts+3-340
......@@ -4,45 +4,24 @@ import * as Schema from "effect/Schema";
44import { HttpClient } from "effect/unstable/http";
55import {
66 EnvironmentCloudEndpointUnavailableError,
7 EnvironmentAuthInvalidError,
87 EnvironmentHttpBadRequestError,
98 EnvironmentHttpConflictError,
109 EnvironmentHttpForbiddenError,
1110 EnvironmentHttpInternalServerError,
1211 EnvironmentHttpUnauthorizedError,
1312} from "@t3tools/contracts";
14import { stripPairingTokenFromUrl } from "@t3tools/shared/remote";
1513import {
16 type RelayEnvironmentConnectResponse as RelayEnvironmentConnectResponseType,
1714 type RelayEnvironmentLinkResponse as RelayEnvironmentLinkResponseType,
18 RelayEnvironmentConnectScope,
19 RelayEnvironmentStatusScope,
20 type RelayDpopAccessTokenScope,
21 type RelayClientEnvironmentRecord,
22 type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType,
2315 type RelayManagedEndpointProviderKind,
2416} from "@t3tools/contracts/relay";
25import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization";
26import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment";
2717import { findErrorTraceId } from "@t3tools/client-runtime/errors";
28import {
29 dpopFailureMessage,
30 ManagedRelay,
31 relayProtectedErrorMessage,
32} from "@t3tools/client-runtime/relay";
18import { ManagedRelay, relayProtectedErrorMessage } from "@t3tools/client-runtime/relay";
3319import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
3420
35import { authClientMetadata } from "../../lib/authClientMetadata";
3621import type { SavedRemoteConnection } from "../../lib/connection";
37import * as MobilePreferences from "../../persistence/mobile-preferences";
3822import * as MobileStorage from "../../persistence/mobile-storage";
3923import { resolveCloudPublicConfig } from "./publicConfig";
4024
41const RELAY_STATUS_AND_CONNECT_SCOPES = [
42 RelayEnvironmentStatusScope,
43 RelayEnvironmentConnectScope,
44] satisfies ReadonlyArray<RelayDpopAccessTokenScope>;
45
4625function readRelayUrl(): string | null {
4726 return resolveCloudPublicConfig().relay.url;
4827}
......@@ -53,12 +32,6 @@ export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmen
5332 readonly traceId?: string;
5433}> {}
5534
56export interface CloudEnvironmentRecordWithStatus {
57 readonly environment: RelayClientEnvironmentRecord;
58 readonly status: RelayEnvironmentStatusResponseType | null;
59 readonly statusError: string | null;
60}
61
6235const isEnvironmentCloudApiError = Schema.is(
6336 Schema.Union([
6437 EnvironmentHttpBadRequestError,
......@@ -69,24 +42,19 @@ const isEnvironmentCloudApiError = Schema.is(
6942 EnvironmentCloudEndpointUnavailableError,
7043 ]),
7144);
72const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError);
7345
7446const MANAGED_ENDPOINT_PROVIDER_KIND =
7547 "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind;
7648
77function cloudEnvironmentLinkError(message: string, options?: { readonly dpop?: boolean }) {
49function cloudEnvironmentLinkError(message: string) {
7850 return (cause: unknown) => {
7951 const environmentError = findEnvironmentCloudApiError(cause);
8052 const traceId = findErrorTraceId(cause);
81 const dpopAuthError = options?.dpop ? findEnvironmentAuthInvalidError(cause) : null;
8253 const detail = environmentError
8354 ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}`
8455 : withDevCause(message, cause);
8556 return new CloudEnvironmentLinkError({
86 message:
87 dpopAuthError?.reason === "invalid_credential"
88 ? dpopFailureMessage(detail, dpopAuthError.dpopFailureReason)
89 : detail,
57 message: detail,
9058 cause,
9159 ...(traceId === null ? {} : { traceId }),
9260 });
......@@ -143,16 +111,6 @@ function findEnvironmentCloudApiError(cause: unknown): { readonly message: strin
143111 return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null;
144112}
145113
146function findEnvironmentAuthInvalidError(cause: unknown): EnvironmentAuthInvalidError | null {
147 if (isEnvironmentAuthInvalidError(cause)) {
148 return cause;
149 }
150 if (typeof cause !== "object" || cause === null) {
151 return null;
152 }
153 return "cause" in cause ? findEnvironmentAuthInvalidError(cause.cause) : null;
154}
155
156114function requireRelayUrl(): Effect.Effect<string, CloudEnvironmentLinkError> {
157115 const relayUrl = readRelayUrl();
158116 return relayUrl
......@@ -186,54 +144,6 @@ function ensureLinkedEnvironmentMatches(input: {
186144 return Effect.void;
187145}
188146
189function endpointMatches(
190 left: RelayClientEnvironmentRecord["endpoint"],
191 right: RelayClientEnvironmentRecord["endpoint"],
192): boolean {
193 return (
194 left.httpBaseUrl === right.httpBaseUrl &&
195 left.wsBaseUrl === right.wsBaseUrl &&
196 left.providerKind === right.providerKind
197 );
198}
199
200function ensureStatusMatchesEnvironment(input: {
201 readonly environment: RelayClientEnvironmentRecord;
202 readonly status: RelayEnvironmentStatusResponseType;
203}): Effect.Effect<void, CloudEnvironmentLinkError> {
204 if (input.status.environmentId !== input.environment.environmentId) {
205 return new CloudEnvironmentLinkError({
206 message: "Relay returned status for a different environment.",
207 });
208 }
209 if (!endpointMatches(input.status.endpoint, input.environment.endpoint)) {
210 return new CloudEnvironmentLinkError({
211 message: "Relay returned status for a different endpoint.",
212 });
213 }
214 if (
215 input.status.descriptor &&
216 input.status.descriptor.environmentId !== input.environment.environmentId
217 ) {
218 return new CloudEnvironmentLinkError({
219 message: "Relay returned status descriptor for a different environment.",
220 });
221 }
222 return Effect.void;
223}
224
225function ensureConnectEndpointMatchesEnvironment(input: {
226 readonly environment: RelayClientEnvironmentRecord;
227 readonly connect: RelayEnvironmentConnectResponseType;
228}): Effect.Effect<void, CloudEnvironmentLinkError> {
229 if (!endpointMatches(input.connect.endpoint, input.environment.endpoint)) {
230 return new CloudEnvironmentLinkError({
231 message: "Relay returned credentials for a different endpoint.",
232 });
233 }
234 return Effect.void;
235}
236
237147interface LinkEnvironmentToCloudInput {
238148 readonly connection: SavedRemoteConnection;
239149 readonly clerkToken: string;
......@@ -328,250 +238,3 @@ export function linkEnvironmentToCloudWithPreference(
328238 );
329239 });
330240}
331
332export function linkEnvironmentToCloud(
333 input: LinkEnvironmentToCloudInput,
334): Effect.Effect<
335 void,
336 CloudEnvironmentLinkError,
337 LinkEnvironmentToCloudRequirements | MobilePreferences.MobilePreferencesStore
338> {
339 return MobilePreferences.MobilePreferencesStore.pipe(
340 Effect.flatMap((preferencesStore) => preferencesStore.load),
341 Effect.mapError(cloudEnvironmentLinkError("Could not load mobile notification preferences.")),
342 Effect.flatMap((preferences) =>
343 linkEnvironmentToCloudWithPreference({
344 ...input,
345 liveActivitiesEnabled: preferences.liveActivitiesEnabled !== false,
346 }),
347 ),
348 );
349}
350
351export function listCloudEnvironments(input: {
352 readonly clerkToken: string;
353}): Effect.Effect<
354 ReadonlyArray<RelayClientEnvironmentRecord>,
355 CloudEnvironmentLinkError,
356 ManagedRelay.ManagedRelayClient
357> {
358 return Effect.gen(function* () {
359 const relayUrl = yield* requireRelayUrl();
360 const relayClient = yield* ManagedRelay.ManagedRelayClient;
361
362 return yield* relayClient
363 .listEnvironments({
364 clerkToken: input.clerkToken,
365 })
366 .pipe(Effect.mapError(decodedRelayClientError(`${relayUrl}/v1/environments failed`)));
367 });
368}
369
370export function getCloudEnvironmentStatus(input: {
371 readonly clerkToken: string;
372 readonly environment: RelayClientEnvironmentRecord;
373 readonly relayScopes?: ReadonlyArray<RelayDpopAccessTokenScope>;
374}): Effect.Effect<
375 RelayEnvironmentStatusResponseType,
376 CloudEnvironmentLinkError,
377 ManagedRelay.ManagedRelayClient
378> {
379 return Effect.gen(function* () {
380 const relayUrl = yield* requireRelayUrl();
381 const relayClient = yield* ManagedRelay.ManagedRelayClient;
382 const status = yield* relayClient
383 .getEnvironmentStatus({
384 clerkToken: input.clerkToken,
385 scopes: input.relayScopes ?? [RelayEnvironmentStatusScope],
386 environmentId: input.environment.environmentId,
387 })
388 .pipe(
389 Effect.mapError(
390 decodedRelayClientError(
391 `${relayUrl}/v1/environments/${encodeURIComponent(input.environment.environmentId)}/status failed`,
392 ),
393 ),
394 );
395 yield* ensureStatusMatchesEnvironment({ environment: input.environment, status });
396 return status;
397 });
398}
399
400export function loadCloudEnvironmentStatuses(input: {
401 readonly clerkToken: string;
402 readonly environments: ReadonlyArray<RelayClientEnvironmentRecord>;
403}): Effect.Effect<
404 ReadonlyArray<CloudEnvironmentRecordWithStatus>,
405 CloudEnvironmentLinkError,
406 ManagedRelay.ManagedRelayClient
407> {
408 return Effect.forEach(
409 input.environments,
410 (environment) =>
411 getCloudEnvironmentStatus({
412 clerkToken: input.clerkToken,
413 environment,
414 relayScopes: RELAY_STATUS_AND_CONNECT_SCOPES,
415 }).pipe(
416 Effect.match({
417 onFailure: (error) => ({
418 environment,
419 status: null,
420 statusError: error.message,
421 }),
422 onSuccess: (status) => ({
423 environment,
424 status,
425 statusError: null,
426 }),
427 }),
428 ),
429 { concurrency: "unbounded" },
430 );
431}
432
433export function listCloudEnvironmentsWithStatus(input: {
434 readonly clerkToken: string;
435}): Effect.Effect<
436 ReadonlyArray<CloudEnvironmentRecordWithStatus>,
437 CloudEnvironmentLinkError,
438 ManagedRelay.ManagedRelayClient
439> {
440 return Effect.gen(function* () {
441 const environments = yield* listCloudEnvironments(input);
442 return yield* loadCloudEnvironmentStatuses({
443 clerkToken: input.clerkToken,
444 environments,
445 });
446 });
447}
448
449const loadAgentAwarenessDeviceId = Effect.fn("mobile.cloud.loadAgentAwarenessDeviceId")(
450 function* () {
451 const storage = yield* MobileStorage.MobileStorage;
452 return yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe(
453 Effect.mapError(cloudEnvironmentLinkError("Could not load the mobile device id.")),
454 );
455 },
456);
457
458const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManagedEnvironment")(
459 function* (input: {
460 readonly clerkToken: string;
461 readonly environmentId: RelayClientEnvironmentRecord["environmentId"];
462 readonly expectedEnvironment?: RelayClientEnvironmentRecord;
463 }) {
464 yield* Effect.annotateCurrentSpan({ "environment.id": input.environmentId });
465 const relayUrl = yield* requireRelayUrl();
466 const relayClient = yield* ManagedRelay.ManagedRelayClient;
467
468 const deviceId = yield* loadAgentAwarenessDeviceId();
469 const connect = yield* relayClient
470 .connectEnvironment({
471 clerkToken: input.clerkToken,
472 scopes: [RelayEnvironmentConnectScope],
473 environmentId: input.environmentId,
474 deviceId,
475 })
476 .pipe(
477 Effect.mapError(
478 decodedRelayClientError(
479 `${relayUrl}/v1/environments/${encodeURIComponent(input.environmentId)}/connect failed`,
480 ),
481 ),
482 );
483 if (connect.environmentId !== input.environmentId) {
484 return yield* new CloudEnvironmentLinkError({
485 message: "Relay returned credentials for a different environment.",
486 });
487 }
488 if (input.expectedEnvironment) {
489 yield* ensureConnectEndpointMatchesEnvironment({
490 environment: input.expectedEnvironment,
491 connect,
492 });
493 }
494
495 const descriptor = yield* fetchRemoteEnvironmentDescriptor({
496 httpBaseUrl: connect.endpoint.httpBaseUrl,
497 }).pipe(
498 Effect.mapError(
499 cloudEnvironmentLinkError("Could not fetch the connected environment descriptor."),
500 ),
501 );
502 if (descriptor.environmentId !== connect.environmentId) {
503 return yield* new CloudEnvironmentLinkError({
504 message: "Connected endpoint descriptor does not match the selected environment.",
505 });
506 }
507 const signer = yield* ManagedRelay.ManagedRelayDpopSigner;
508 const bootstrapDpop = yield* signer
509 .createProof({
510 method: "POST",
511 url: new URL("/oauth/token", connect.endpoint.httpBaseUrl).toString(),
512 })
513 .pipe(Effect.mapError(cloudEnvironmentLinkError("Could not create bootstrap DPoP proof.")));
514 const bootstrap = yield* exchangeRemoteDpopAccessToken({
515 httpBaseUrl: connect.endpoint.httpBaseUrl,
516 credential: connect.credential,
517 dpopProof: bootstrapDpop,
518 clientMetadata: authClientMetadata(),
519 }).pipe(
520 Effect.mapError(
521 cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token.", {
522 dpop: true,
523 }),
524 ),
525 );
526 const pairingUrl = new URL(connect.endpoint.httpBaseUrl);
527 pairingUrl.hash = new URLSearchParams([["token", connect.credential]]).toString();
528
529 return {
530 environmentId: descriptor.environmentId,
531 environmentLabel: descriptor.label,
532 pairingUrl: stripPairingTokenFromUrl(pairingUrl).toString(),
533 displayUrl: connect.endpoint.httpBaseUrl,
534 httpBaseUrl: connect.endpoint.httpBaseUrl,
535 wsBaseUrl: connect.endpoint.wsBaseUrl,
536 bearerToken: null,
537 authenticationMethod: "dpop",
538 dpopAccessToken: bootstrap.access_token,
539 relayManaged: true,
540 } satisfies SavedRemoteConnection;
541 },
542);
543
544export function connectCloudEnvironment(input: {
545 readonly clerkToken: string;
546 readonly environment: RelayClientEnvironmentRecord;
547}): Effect.Effect<
548 SavedRemoteConnection,
549 CloudEnvironmentLinkError,
550 | HttpClient.HttpClient
551 | ManagedRelay.ManagedRelayClient
552 | ManagedRelay.ManagedRelayDpopSigner
553 | MobileStorage.MobileStorage
554> {
555 return connectRelayManagedEnvironment({
556 clerkToken: input.clerkToken,
557 environmentId: input.environment.environmentId,
558 expectedEnvironment: input.environment,
559 });
560}
561
562export function refreshCloudEnvironmentConnection(input: {
563 readonly clerkToken: string;
564 readonly connection: SavedRemoteConnection;
565}): Effect.Effect<
566 SavedRemoteConnection,
567 CloudEnvironmentLinkError,
568 | HttpClient.HttpClient
569 | ManagedRelay.ManagedRelayClient
570 | ManagedRelay.ManagedRelayDpopSigner
571 | MobileStorage.MobileStorage
572> {
573 return connectRelayManagedEnvironment({
574 clerkToken: input.clerkToken,
575 environmentId: input.connection.environmentId,
576 });
577}
apps/mobile/src/features/review/reviewPerf.ts-27
......@@ -42,33 +42,6 @@ export function measureReviewWork<T>(name: string, callback: () => T): T {
4242 }
4343}
4444
45export async function measureReviewAsyncWork<T>(
46 name: string,
47 callback: () => Promise<T>,
48): Promise<T> {
49 if (!isReviewPerfEnabled()) {
50 return callback();
51 }
52
53 const perf = getPerformance();
54 const marker = `${REVIEW_PERF_PREFIX}.${name}.${reviewPerfSequence++}`;
55 const startMark = `${marker}.start`;
56 const endMark = `${marker}.end`;
57 const startedAt = perf?.now?.() ?? Date.now();
58
59 perf?.mark?.(startMark);
60 try {
61 return await callback();
62 } finally {
63 const durationMs = (perf?.now?.() ?? Date.now()) - startedAt;
64 perf?.mark?.(endMark);
65 perf?.measure?.(`${REVIEW_PERF_PREFIX}.${name}`, startMark, endMark);
66 perf?.clearMarks?.(startMark);
67 perf?.clearMarks?.(endMark);
68 console.log(`[review-perf] ${name}`, { durationMs: Number(durationMs.toFixed(2)) });
69 }
70}
71
7245export function markReviewEvent(name: string, details?: Record<string, unknown>): void {
7346 if (!isReviewPerfEnabled()) {
7447 return;
apps/mobile/src/features/review/shikiReviewHighlighter.ts+6-49
......@@ -15,14 +15,12 @@ import * as Schema from "effect/Schema";
1515import {
1616 resolveReviewHighlighterEngine,
1717 resolveReviewHighlighterEnginePreference,
18 type ReviewHighlighterEngine,
1918} from "./reviewHighlighterEngine";
2019import { createIncrementalSnippet } from "./incrementalSnippet";
2120import type { ReviewRenderableLineRow } from "./reviewModel";
2221import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs";
2322
2423export type ReviewDiffTheme = "light" | "dark";
25export type { ReviewHighlighterEngine };
2624
2725export class ReviewHighlighterEngineInitializationError extends Schema.TaggedError<ReviewHighlighterEngineInitializationError>()(
2826 "ReviewHighlighterEngineInitializationError",
......@@ -181,7 +179,6 @@ const languageAliases: Record<string, string> = {
181179 txt: "text",
182180};
183181let highlighterPromise: Promise<HighlighterCore> | null = null;
184let activeHighlighterEnginePromise: Promise<ReviewHighlighterEngine> | null = null;
185182
186183type LoadedLanguageModule = {
187184 default: Parameters<HighlighterCore["loadLanguage"]>[0];
......@@ -254,10 +251,7 @@ async function getHighlighter(): Promise<HighlighterCore> {
254251 engine: nativeEngineModule.createNativeEngine(),
255252 });
256253 logReviewHighlighterDiagnostic("using native engine");
257 return {
258 highlighter,
259 engine: "native" as const,
260 };
254 return highlighter;
261255 }
262256 } catch (error) {
263257 nativeInitializationError = new ReviewHighlighterEngineInitializationError({
......@@ -308,55 +302,18 @@ async function getHighlighter(): Promise<HighlighterCore> {
308302 logReviewHighlighterDiagnostic("using javascript engine", {
309303 resolvedEngine: engine,
310304 });
311 return {
312 highlighter,
313 engine,
314 };
305 return highlighter;
315306 })();
316307
317 highlighterPromise = configuredHighlighterPromise
318 .then((result) => result.highlighter)
319 .catch((error) => {
320 highlighterPromise = null;
321 activeHighlighterEnginePromise = null;
322 throw error;
323 });
324 activeHighlighterEnginePromise = configuredHighlighterPromise
325 .then((result) => result.engine)
326 .catch((error) => {
327 activeHighlighterEnginePromise = null;
328 throw error;
329 });
308 highlighterPromise = configuredHighlighterPromise.catch((error) => {
309 highlighterPromise = null;
310 throw error;
311 });
330312 }
331313
332314 return highlighterPromise;
333315}
334316
335export async function getActiveReviewHighlighterEngine(): Promise<ReviewHighlighterEngine> {
336 await getHighlighter();
337 return activeHighlighterEnginePromise ?? Promise.resolve("javascript");
338}
339
340export async function prepareReviewHighlighter(): Promise<void> {
341 await getHighlighter();
342}
343
344export async function prepareReviewHighlighterLanguages(
345 languages: ReadonlyArray<string>,
346): Promise<void> {
347 const highlighter = await getHighlighter();
348 await Promise.all(
349 languages.map(async (language) => {
350 const candidate = resolveLanguageAlias(language);
351 if (candidate === "text" || !(candidate in languageImports)) {
352 return;
353 }
354
355 await loadSingleLanguage(highlighter, candidate);
356 }),
357 );
358}
359
360317function resolveLanguageAlias(language: string): string {
361318 const normalized = language.toLowerCase();
362319 return languageAliases[normalized] ?? normalized;
apps/server/integration/OrchestrationEngineHarness.integration.ts-9
......@@ -28,11 +28,9 @@ import * as CheckpointStore from "../src/checkpointing/CheckpointStore.ts";
2828import { TextGeneration } from "../src/textGeneration/TextGeneration.ts";
2929import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";
3030import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts";
31import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts";
3231import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts";
3332import * as ProviderSessionRuntime from "../src/persistence/ProviderSessionRuntime.ts";
3433import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts";
35import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts";
3634import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts";
3735import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts";
3836import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";
......@@ -189,7 +187,6 @@ export interface OrchestrationIntegrationHarness {
189187 readonly snapshotQuery: ProjectionSnapshotQuery["Service"];
190188 readonly providerService: ProviderService["Service"];
191189 readonly checkpointStore: CheckpointStore.CheckpointStore["Service"];
192 readonly checkpointRepository: ProjectionCheckpointRepository["Service"];
193190 readonly pendingApprovalRepository: ProjectionPendingApprovalRepository["Service"];
194191 readonly waitForThread: (
195192 threadId: string,
......@@ -313,7 +310,6 @@ export const makeOrchestrationIntegrationHarness = (
313310 const runtimeServicesLayer = Layer.mergeAll(
314311 projectionSnapshotQueryLayer,
315312 orchestrationLayer.pipe(Layer.provide(projectionSnapshotQueryLayer)),
316 ProjectionCheckpointRepositoryLive,
317313 ProjectionPendingApprovalRepositoryLive,
318314 checkpointStoreLayer,
319315 providerLayer,
......@@ -463,10 +459,6 @@ export const makeOrchestrationIntegrationHarness = (
463459 const checkpointStore = yield* tryRuntimePromise("load CheckpointStore service", () =>
464460 runtime.runPromise(Effect.service(CheckpointStore.CheckpointStore)),
465461 ).pipe(Effect.orDie);
466 const checkpointRepository = yield* tryRuntimePromise(
467 "load ProjectionCheckpointRepository service",
468 () => runtime.runPromise(Effect.service(ProjectionCheckpointRepository)),
469 ).pipe(Effect.orDie);
470462 const pendingApprovalRepository = yield* tryRuntimePromise(
471463 "load ProjectionPendingApprovalRepository service",
472464 () => runtime.runPromise(Effect.service(ProjectionPendingApprovalRepository)),
......@@ -612,7 +604,6 @@ export const makeOrchestrationIntegrationHarness = (
612604 snapshotQuery,
613605 providerService,
614606 checkpointStore,
615 checkpointRepository,
616607 pendingApprovalRepository,
617608 waitForThread,
618609 waitForDomainEvent,
apps/server/integration/orchestrationEngine.integration.test.ts+3-26
......@@ -20,7 +20,6 @@ import {
2020import { assert, it } from "@effect/vitest";
2121import * as Clock from "effect/Clock";
2222import * as Effect from "effect/Effect";
23import * as Option from "effect/Option";
2423import * as Schema from "effect/Schema";
2524
2625import type { TestTurnResponse } from "./TestProviderAdapter.integration.ts";
......@@ -247,14 +246,7 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite +
247246 );
248247 assert.equal(thread.checkpoints[0]?.status, "ready");
249248 assert.equal(thread.checkpoints[0]?.checkpointTurnCount, 1);
250
251 const checkpointRows = yield* harness.checkpointRepository.listByThreadId({
252 threadId: THREAD_ID,
253 });
254 assert.equal(checkpointRows.length, 1);
255 assert.equal(checkpointRows[0]?.checkpointTurnCount, 1);
256 assert.equal(checkpointRows[0]?.status, "ready");
257 assert.deepEqual(checkpointRows[0]?.files, []);
249 assert.deepEqual(thread.checkpoints[0]?.files, []);
258250
259251 const ref0 = checkpointRefForThreadTurn(THREAD_ID, 0);
260252 const ref1 = checkpointRefForThreadTurn(THREAD_ID, 1);
......@@ -498,11 +490,8 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
498490 true,
499491 );
500492
501 const checkpointRows = yield* harness.checkpointRepository.listByThreadId({
502 threadId: THREAD_ID,
503 });
504493 assert.deepEqual(
505 checkpointRows.map((row) => row.checkpointTurnCount),
494 secondTurnThread.checkpoints.map((row) => row.checkpointTurnCount),
506495 [1, 2],
507496 );
508497
......@@ -688,14 +677,7 @@ it.live("records failed turn runtime state and checkpoint status as error", () =
688677 assert.equal(thread.session?.status, "error");
689678 assert.equal(thread.checkpoints[0]?.status, "error");
690679
691 const checkpointRow = yield* harness.checkpointRepository.getByThreadAndTurnCount({
692 threadId: THREAD_ID,
693 checkpointTurnCount: 1,
694 });
695 assert.equal(Option.isSome(checkpointRow), true);
696 if (Option.isSome(checkpointRow)) {
697 assert.equal(checkpointRow.value.status, "error");
698 }
680 assert.equal(thread.checkpoints[0]?.checkpointTurnCount, 1);
699681 assert.equal(
700682 gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 1)),
701683 true,
......@@ -878,11 +860,6 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
878860 false,
879861 );
880862 assert.deepEqual(harness.adapterHarness!.getRollbackCalls(THREAD_ID), [1]);
881
882 const checkpointRows = yield* harness.checkpointRepository.listByThreadId({
883 threadId: THREAD_ID,
884 });
885 assert.equal(checkpointRows.length, 1);
886863 }),
887864 ),
888865);
apps/server/src/orchestration/Errors.ts+1-44
......@@ -4,30 +4,6 @@ import * as Schema from "effect/Schema";
44
55import type { ProjectionRepositoryError } from "../persistence/Errors.ts";
66
7export class OrchestrationCommandJsonParseError extends Schema.TaggedError<OrchestrationCommandJsonParseError>()(
8 "OrchestrationCommandJsonParseError",
9 {
10 detail: Schema.String,
11 cause: Schema.optional(Schema.Defect()),
12 },
13) {
14 override get message(): string {
15 return `Invalid orchestration command JSON: ${this.detail}`;
16 }
17}
18
19export class OrchestrationCommandDecodeError extends Schema.TaggedError<OrchestrationCommandDecodeError>()(
20 "OrchestrationCommandDecodeError",
21 {
22 issue: Schema.String,
23 cause: Schema.optional(Schema.Defect()),
24 },
25) {
26 override get message(): string {
27 return `Invalid orchestration command payload: ${this.issue}`;
28 }
29}
30
317export class OrchestrationCommandInvariantError extends Schema.TaggedError<OrchestrationCommandInvariantError>()(
328 "OrchestrationCommandInvariantError",
339 {
......@@ -100,31 +76,12 @@ export class OrchestrationProjectorDecodeError extends Schema.TaggedError<Orches
10076 }
10177}
10278
103export class OrchestrationListenerCallbackError extends Schema.TaggedError<OrchestrationListenerCallbackError>()(
104 "OrchestrationListenerCallbackError",
105 {
106 listener: Schema.Literals(["read-model", "domain-event"]),
107 detail: Schema.String,
108 cause: Schema.optional(Schema.Defect()),
109 },
110) {
111 override get message(): string {
112 return `Orchestration ${this.listener} listener failed: ${this.detail}`;
113 }
114}
115
11679export type OrchestrationDispatchError =
11780 | ProjectionRepositoryError
11881 | OrchestrationCommandRejection
11982 | OrchestrationCommandIdConflictError
12083 | OrchestrationCommandPreviouslyRejectedError
121 | OrchestrationProjectorDecodeError
122 | OrchestrationListenerCallbackError;
123
124export type OrchestrationEngineError =
125 | OrchestrationDispatchError
126 | OrchestrationCommandJsonParseError
127 | OrchestrationCommandDecodeError;
84 | OrchestrationProjectorDecodeError;
12885
12986export function toProjectorDecodeError(eventType: string) {
13087 return (error: Schema.SchemaError): OrchestrationProjectorDecodeError =>
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+11-6
......@@ -8,6 +8,7 @@ import {
88 MessageId,
99 NonNegativeInt,
1010 OrchestrationCheckpointFile,
11 OrchestrationCheckpointStatus,
1112 OrchestrationProposedPlanId,
1213 OrchestrationReadModel,
1314 OrchestrationThreadSearchSource,
......@@ -52,7 +53,6 @@ import {
5253 toPersistenceSqlError,
5354 type ProjectionRepositoryError,
5455} from "../../persistence/Errors.ts";
55import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts";
5656import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts";
5757import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts";
5858import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts";
......@@ -151,11 +151,16 @@ const ProjectionThreadRuntimeContextDbRowSchema = Schema.Struct({
151151 title: Schema.String,
152152 session: Schema.NullOr(ProjectionThreadSessionDbRowSchema),
153153});
154const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields(
155 Struct.assign({
156 files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)),
157 }),
158);
154const ProjectionCheckpointDbRowSchema = Schema.Struct({
155 threadId: ThreadId,
156 turnId: TurnId,
157 checkpointTurnCount: NonNegativeInt,
158 checkpointRef: CheckpointRef,
159 status: OrchestrationCheckpointStatus,
160 files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)),
161 assistantMessageId: Schema.NullOr(MessageId),
162 completedAt: IsoDateTime,
163});
159164const ProjectionLatestTurnDbRowSchema = Schema.Struct({
160165 threadId: ProjectionThread.fields.threadId,
161166 turnId: TurnId,
apps/server/src/persistence/Errors.test.ts+1-1
......@@ -42,7 +42,7 @@ it.effect("names a real SQLite condition without copying query data", () =>
4242 assert.equal(error.cause, cause);
4343 assert.notInclude(error.message, payload);
4444 assert.notInclude(error.message, "private_column");
45 }).pipe(Effect.provide(NodeSqliteClient.layerMemory())),
45 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
4646);
4747
4848it("reads the condition through a wrapping driver error", () => {
apps/server/src/persistence/Errors.ts-34
......@@ -124,42 +124,8 @@ export function toPersistenceDecodeError(operation: string) {
124124export const isPersistenceError = (u: unknown) =>
125125 isPersistenceSqlError(u) || isPersistenceDecodeError(u);
126126
127// ===============================
128// Provider Session Repository Errors
129// ===============================
130
131export class ProviderSessionRepositoryValidationError extends Schema.TaggedError<ProviderSessionRepositoryValidationError>()(
132 "ProviderSessionRepositoryValidationError",
133 {
134 operation: Schema.String,
135 issue: Schema.String,
136 cause: Schema.optional(Schema.Defect()),
137 },
138) {
139 override get message(): string {
140 return `Provider session repository validation failed in ${this.operation}: ${this.issue}`;
141 }
142}
143
144export class ProviderSessionRepositoryPersistenceError extends Schema.TaggedError<ProviderSessionRepositoryPersistenceError>()(
145 "ProviderSessionRepositoryPersistenceError",
146 {
147 operation: Schema.String,
148 detail: Schema.String,
149 cause: Schema.optional(Schema.Defect()),
150 },
151) {
152 override get message(): string {
153 return `Provider session repository persistence error in ${this.operation}: ${this.detail}`;
154 }
155}
156
157127export type OrchestrationEventStoreError = PersistenceSqlError | PersistenceDecodeError;
158128
159export type ProviderSessionRepositoryError =
160 | ProviderSessionRepositoryValidationError
161 | ProviderSessionRepositoryPersistenceError;
162
163129export type OrchestrationCommandReceiptRepositoryError =
164130 | PersistenceSqlError
165131 | PersistenceDecodeError;
apps/server/src/persistence/Layers/ProjectionCheckpoints.ts deleted-217
......@@ -1,217 +0,0 @@
1import { OrchestrationCheckpointFile } from "@t3tools/contracts";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3import * as SqlSchema from "effect/unstable/sql/SqlSchema";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";
8import * as Struct from "effect/Struct";
9
10import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts";
11import {
12 DeleteByThreadIdInput,
13 GetByThreadAndTurnCountInput,
14 ListByThreadIdInput,
15 ProjectionCheckpoint,
16 ProjectionCheckpointRepository,
17 type ProjectionCheckpointRepositoryShape,
18} from "../Services/ProjectionCheckpoints.ts";
19
20const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields(
21 Struct.assign({
22 files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)),
23 }),
24);
25
26function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
27 return (cause: unknown) =>
28 Schema.isSchemaError(cause)
29 ? toPersistenceDecodeError(decodeOperation)(cause)
30 : toPersistenceSqlError(sqlOperation)(cause);
31}
32
33const makeProjectionCheckpointRepository = Effect.gen(function* () {
34 const sql = yield* SqlClient.SqlClient;
35
36 const clearCheckpointConflict = SqlSchema.void({
37 Request: GetByThreadAndTurnCountInput,
38 execute: ({ threadId, checkpointTurnCount }) =>
39 sql`
40 UPDATE projection_turns
41 SET
42 checkpoint_turn_count = NULL,
43 checkpoint_ref = NULL,
44 checkpoint_status = NULL,
45 checkpoint_files_json = '[]'
46 WHERE thread_id = ${threadId}
47 AND checkpoint_turn_count = ${checkpointTurnCount}
48 `,
49 });
50
51 const upsertProjectionCheckpointRow = SqlSchema.void({
52 Request: ProjectionCheckpointDbRowSchema,
53 execute: (row) =>
54 sql`
55 INSERT INTO projection_turns (
56 thread_id,
57 turn_id,
58 pending_message_id,
59 assistant_message_id,
60 state,
61 requested_at,
62 started_at,
63 completed_at,
64 checkpoint_turn_count,
65 checkpoint_ref,
66 checkpoint_status,
67 checkpoint_files_json
68 )
69 VALUES (
70 ${row.threadId},
71 ${row.turnId},
72 NULL,
73 ${row.assistantMessageId},
74 ${row.status === "error" ? "error" : "completed"},
75 ${row.completedAt},
76 ${row.completedAt},
77 ${row.completedAt},
78 ${row.checkpointTurnCount},
79 ${row.checkpointRef},
80 ${row.status},
81 ${row.files}
82 )
83 ON CONFLICT (thread_id, turn_id)
84 DO UPDATE SET
85 assistant_message_id = excluded.assistant_message_id,
86 state = excluded.state,
87 completed_at = excluded.completed_at,
88 checkpoint_turn_count = excluded.checkpoint_turn_count,
89 checkpoint_ref = excluded.checkpoint_ref,
90 checkpoint_status = excluded.checkpoint_status,
91 checkpoint_files_json = excluded.checkpoint_files_json
92 `,
93 });
94
95 const listProjectionCheckpointRows = SqlSchema.findAll({
96 Request: ListByThreadIdInput,
97 Result: ProjectionCheckpointDbRowSchema,
98 execute: ({ threadId }) =>
99 sql`
100 SELECT
101 thread_id AS "threadId",
102 turn_id AS "turnId",
103 checkpoint_turn_count AS "checkpointTurnCount",
104 checkpoint_ref AS "checkpointRef",
105 checkpoint_status AS "status",
106 checkpoint_files_json AS "files",
107 assistant_message_id AS "assistantMessageId",
108 completed_at AS "completedAt"
109 FROM projection_turns
110 WHERE thread_id = ${threadId}
111 AND checkpoint_turn_count IS NOT NULL
112 ORDER BY checkpoint_turn_count ASC
113 `,
114 });
115
116 const getProjectionCheckpointRow = SqlSchema.findOneOption({
117 Request: GetByThreadAndTurnCountInput,
118 Result: ProjectionCheckpointDbRowSchema,
119 execute: ({ threadId, checkpointTurnCount }) =>
120 sql`
121 SELECT
122 thread_id AS "threadId",
123 turn_id AS "turnId",
124 checkpoint_turn_count AS "checkpointTurnCount",
125 checkpoint_ref AS "checkpointRef",
126 checkpoint_status AS "status",
127 checkpoint_files_json AS "files",
128 assistant_message_id AS "assistantMessageId",
129 completed_at AS "completedAt"
130 FROM projection_turns
131 WHERE thread_id = ${threadId}
132 AND checkpoint_turn_count = ${checkpointTurnCount}
133 `,
134 });
135
136 const deleteProjectionCheckpointRows = SqlSchema.void({
137 Request: DeleteByThreadIdInput,
138 execute: ({ threadId }) =>
139 sql`
140 UPDATE projection_turns
141 SET
142 checkpoint_turn_count = NULL,
143 checkpoint_ref = NULL,
144 checkpoint_status = NULL,
145 checkpoint_files_json = '[]'
146 WHERE thread_id = ${threadId}
147 AND checkpoint_turn_count IS NOT NULL
148 `,
149 });
150
151 const upsertCheckpointRow = (row: Schema.Schema.Type<typeof ProjectionCheckpointDbRowSchema>) =>
152 sql.withTransaction(
153 clearCheckpointConflict({
154 threadId: row.threadId,
155 checkpointTurnCount: row.checkpointTurnCount,
156 }).pipe(Effect.flatMap(() => upsertProjectionCheckpointRow(row))),
157 );
158
159 const upsert: ProjectionCheckpointRepositoryShape["upsert"] = (row) =>
160 upsertCheckpointRow(row).pipe(
161 Effect.mapError(
162 toPersistenceSqlOrDecodeError(
163 "ProjectionCheckpointRepository.upsert:query",
164 "ProjectionCheckpointRepository.upsert:encodeRequest",
165 ),
166 ),
167 );
168
169 const listByThreadId: ProjectionCheckpointRepositoryShape["listByThreadId"] = (input) =>
170 listProjectionCheckpointRows(input).pipe(
171 Effect.mapError(
172 toPersistenceSqlOrDecodeError(
173 "ProjectionCheckpointRepository.listByThreadId:query",
174 "ProjectionCheckpointRepository.listByThreadId:decodeRows",
175 ),
176 ),
177 Effect.map((rows) => rows as ReadonlyArray<Schema.Schema.Type<typeof ProjectionCheckpoint>>),
178 );
179
180 const getByThreadAndTurnCount: ProjectionCheckpointRepositoryShape["getByThreadAndTurnCount"] = (
181 input,
182 ) =>
183 getProjectionCheckpointRow(input).pipe(
184 Effect.mapError(
185 toPersistenceSqlOrDecodeError(
186 "ProjectionCheckpointRepository.getByThreadAndTurnCount:query",
187 "ProjectionCheckpointRepository.getByThreadAndTurnCount:decodeRow",
188 ),
189 ),
190 Effect.flatMap((rowOption) =>
191 Option.match(rowOption, {
192 onNone: () => Effect.succeed(Option.none()),
193 onSome: (row) =>
194 Effect.succeed(Option.some(row as Schema.Schema.Type<typeof ProjectionCheckpoint>)),
195 }),
196 ),
197 );
198
199 const deleteByThreadId: ProjectionCheckpointRepositoryShape["deleteByThreadId"] = (input) =>
200 deleteProjectionCheckpointRows(input).pipe(
201 Effect.mapError(
202 toPersistenceSqlError("ProjectionCheckpointRepository.deleteByThreadId:query"),
203 ),
204 );
205
206 return {
207 upsert,
208 listByThreadId,
209 getByThreadAndTurnCount,
210 deleteByThreadId,
211 } satisfies ProjectionCheckpointRepositoryShape;
212});
213
214export const ProjectionCheckpointRepositoryLive = Layer.effect(
215 ProjectionCheckpointRepository,
216 makeProjectionCheckpointRepository,
217);
apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts-20
......@@ -7,7 +7,6 @@ import * as Schema from "effect/Schema";
77import { toPersistenceSqlError } from "../Errors.ts";
88import {
99 GetProjectionPendingApprovalInput,
10 DeleteProjectionPendingApprovalInput,
1110 ListProjectionPendingApprovalsInput,
1211 ProjectionPendingApproval,
1312 ProjectionPendingApprovalRepository,
......@@ -97,15 +96,6 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () {
9796 `,
9897 });
9998
100 const deleteProjectionPendingApprovalRow = SqlSchema.void({
101 Request: DeleteProjectionPendingApprovalInput,
102 execute: ({ requestId }) =>
103 sql`
104 DELETE FROM projection_pending_approvals
105 WHERE request_id = ${requestId}
106 `,
107 });
108
10999 const deleteProjectionPendingApprovalRowsByThread = SqlSchema.void({
110100 Request: ListProjectionPendingApprovalsInput,
111101 execute: ({ threadId }) =>
......@@ -143,15 +133,6 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () {
143133 ),
144134 );
145135
146 const deleteByRequestId: ProjectionPendingApprovalRepositoryShape["deleteByRequestId"] = (
147 input,
148 ) =>
149 deleteProjectionPendingApprovalRow(input).pipe(
150 Effect.mapError(
151 toPersistenceSqlError("ProjectionPendingApprovalRepository.deleteByRequestId:query"),
152 ),
153 );
154
155136 const deleteByThreadId: ProjectionPendingApprovalRepositoryShape["deleteByThreadId"] = (input) =>
156137 deleteProjectionPendingApprovalRowsByThread(input).pipe(
157138 Effect.mapError(
......@@ -164,7 +145,6 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () {
164145 listByThreadId,
165146 countPendingByThreadId,
166147 getByRequestId,
167 deleteByRequestId,
168148 deleteByThreadId,
169149 } satisfies ProjectionPendingApprovalRepositoryShape;
170150});
apps/server/src/persistence/Layers/ProjectionProjects.ts-47
......@@ -9,7 +9,6 @@ import * as Struct from "effect/Struct";
99import { ModelSelection, ProjectIconOverride, ProjectScript } from "@t3tools/contracts";
1010import { toPersistenceSqlError } from "../Errors.ts";
1111import {
12 DeleteProjectionProjectInput,
1312 GetProjectionProjectInput,
1413 ProjectionProject,
1514 ProjectionProjectRepository,
......@@ -24,7 +23,6 @@ const ProjectionProjectDbRow = ProjectionProject.mapFields(
2423 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
2524 }),
2625);
27type ProjectionProjectDbRow = typeof ProjectionProjectDbRow.Type;
2826
2927const makeProjectionProjectRepository = Effect.gen(function* () {
3028 const sql = yield* SqlClient.SqlClient;
......@@ -100,38 +98,6 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
10098 `,
10199 });
102100
103 const listProjectionProjectRows = SqlSchema.findAll({
104 Request: Schema.Void,
105 Result: ProjectionProjectDbRow,
106 execute: () =>
107 sql`
108 SELECT
109 project_id AS "projectId",
110 title,
111 workspace_root AS "workspaceRoot",
112 default_model_selection_json AS "defaultModelSelection",
113 default_thread_env_mode AS "defaultThreadEnvMode",
114 auto_pull AS "autoPull",
115 favicon_path AS "faviconPath",
116 project_icon_json AS "projectIcon",
117 scripts_json AS "scripts",
118 created_at AS "createdAt",
119 updated_at AS "updatedAt",
120 deleted_at AS "deletedAt"
121 FROM projection_projects
122 ORDER BY created_at ASC, project_id ASC
123 `,
124 });
125
126 const deleteProjectionProjectRow = SqlSchema.void({
127 Request: DeleteProjectionProjectInput,
128 execute: ({ projectId }) =>
129 sql`
130 DELETE FROM projection_projects
131 WHERE project_id = ${projectId}
132 `,
133 });
134
135101 const upsert: ProjectionProjectRepositoryShape["upsert"] = (row) =>
136102 upsertProjectionProjectRow(row).pipe(
137103 Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.upsert:query")),
......@@ -143,22 +109,9 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
143109 Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.getById:query")),
144110 );
145111
146 const listAll: ProjectionProjectRepositoryShape["listAll"] = () =>
147 listProjectionProjectRows().pipe(
148 Effect.map((rows) => rows.map((row) => ({ ...row, autoPull: row.autoPull === 1 }))),
149 Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.listAll:query")),
150 );
151
152 const deleteById: ProjectionProjectRepositoryShape["deleteById"] = (input) =>
153 deleteProjectionProjectRow(input).pipe(
154 Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.deleteById:query")),
155 );
156
157112 return {
158113 upsert,
159114 getById,
160 listAll,
161 deleteById,
162115 } satisfies ProjectionProjectRepositoryShape;
163116});
164117
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts-3
......@@ -518,9 +518,6 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
518518 assert.deepStrictEqual(Option.getOrNull(persisted)?.linkedPullRequest, linkedPullRequest);
519519 assert.deepStrictEqual(Option.getOrNull(persisted)?.branchPullRequest, branchPullRequest);
520520
521 const listed = yield* threads.listByProjectId({ projectId: linkedPullRequest.projectId });
522 assert.deepStrictEqual(listed[0]?.branchPullRequest, branchPullRequest);
523
524521 const row = Option.getOrNull(persisted);
525522 if (row === null) return yield* Effect.die("Expected linked thread row to exist.");
526523 yield* threads.upsert({ ...row, linkedPullRequest: null });
apps/server/src/persistence/Layers/ProjectionState.ts-25
......@@ -1,4 +1,3 @@
1import { NonNegativeInt } from "@t3tools/contracts";
21import * as SqlClient from "effect/unstable/sql/SqlClient";
32import * as SqlSchema from "effect/unstable/sql/SqlSchema";
43import * as Effect from "effect/Effect";
......@@ -14,10 +13,6 @@ import {
1413 ProjectionState,
1514} from "../Services/ProjectionState.ts";
1615
17const MinLastAppliedSequenceRowSchema = Schema.Struct({
18 minLastAppliedSequence: Schema.NullOr(NonNegativeInt),
19});
20
2116const makeProjectionStateRepository = Effect.gen(function* () {
2217 const sql = yield* SqlClient.SqlClient;
2318
......@@ -90,17 +85,6 @@ const makeProjectionStateRepository = Effect.gen(function* () {
9085 `,
9186 });
9287
93 const readMinLastAppliedSequence = SqlSchema.findOne({
94 Request: Schema.Void,
95 Result: MinLastAppliedSequenceRowSchema,
96 execute: () =>
97 sql`
98 SELECT
99 MIN(last_applied_sequence) AS "minLastAppliedSequence"
100 FROM projection_state
101 `,
102 });
103
10488 const upsert: ProjectionStateRepositoryShape["upsert"] = (row) =>
10589 upsertProjectionStateRow(row).pipe(
10690 Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.upsert:query")),
......@@ -121,20 +105,11 @@ const makeProjectionStateRepository = Effect.gen(function* () {
121105 Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.listAll:query")),
122106 );
123107
124 const minLastAppliedSequence: ProjectionStateRepositoryShape["minLastAppliedSequence"] = () =>
125 readMinLastAppliedSequence(undefined).pipe(
126 Effect.mapError(
127 toPersistenceSqlError("ProjectionStateRepository.minLastAppliedSequence:query"),
128 ),
129 Effect.map((row) => row.minLastAppliedSequence),
130 );
131
132108 return {
133109 upsert,
134110 upsertMany,
135111 getByProjector,
136112 listAll,
137 minLastAppliedSequence,
138113 } satisfies ProjectionStateRepositoryShape;
139114});
140115
apps/server/src/persistence/Layers/ProjectionThreads.ts-66
......@@ -7,9 +7,7 @@ import * as Struct from "effect/Struct";
77
88import { toPersistenceSqlError } from "../Errors.ts";
99import {
10 DeleteProjectionThreadInput,
1110 GetProjectionThreadInput,
12 ListProjectionThreadsByProjectInput,
1311 ProjectionThread,
1412 ProjectionThreadRepository,
1513 type ProjectionThreadRepositoryShape,
......@@ -24,7 +22,6 @@ const ProjectionThreadDbRow = ProjectionThread.mapFields(
2422 branchPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)),
2523 }),
2624);
27type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type;
2825
2926const makeProjectionThreadRepository = Effect.gen(function* () {
3027 const sql = yield* SqlClient.SqlClient;
......@@ -172,57 +169,6 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
172169 `,
173170 });
174171
175 const listProjectionThreadRows = SqlSchema.findAll({
176 Request: ListProjectionThreadsByProjectInput,
177 Result: ProjectionThreadDbRow,
178 execute: ({ projectId }) =>
179 sql`
180 SELECT
181 thread_id AS "threadId",
182 project_id AS "projectId",
183 title,
184 title_state_json AS "titleState",
185 model_selection_json AS "modelSelection",
186 runtime_mode AS "runtimeMode",
187 interaction_mode AS "interactionMode",
188 branch,
189 worktree_path AS "worktreePath",
190 linked_pull_request_json AS "linkedPullRequest",
191 branch_pull_request_json AS "branchPullRequest",
192 latest_turn_id AS "latestTurnId",
193 created_at AS "createdAt",
194 updated_at AS "updatedAt",
195 archived_at AS "archivedAt",
196 settled_override AS "settledOverride",
197 settled_at AS "settledAt",
198 unsettled_at AS "unsettledAt",
199 snoozed_until AS "snoozedUntil",
200 snoozed_at AS "snoozedAt",
201 pinned_at AS "pinnedAt",
202 pin_order_key AS "pinOrderKey",
203 active_order_key AS "activeOrderKey",
204 title_regeneration_request_id AS "titleRegenerationRequestId",
205 title_regeneration_started_at AS "titleRegenerationStartedAt",
206 latest_user_message_at AS "latestUserMessageAt",
207 pending_approval_count AS "pendingApprovalCount",
208 pending_user_input_count AS "pendingUserInputCount",
209 has_actionable_proposed_plan AS "hasActionableProposedPlan",
210 deleted_at AS "deletedAt"
211 FROM projection_threads
212 WHERE project_id = ${projectId}
213 ORDER BY created_at ASC, thread_id ASC
214 `,
215 });
216
217 const deleteProjectionThreadRow = SqlSchema.void({
218 Request: DeleteProjectionThreadInput,
219 execute: ({ threadId }) =>
220 sql`
221 DELETE FROM projection_threads
222 WHERE thread_id = ${threadId}
223 `,
224 });
225
226172 const upsert: ProjectionThreadRepositoryShape["upsert"] = (row) =>
227173 upsertProjectionThreadRow(row).pipe(
228174 Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.upsert:query")),
......@@ -233,21 +179,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
233179 Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.getById:query")),
234180 );
235181
236 const listByProjectId: ProjectionThreadRepositoryShape["listByProjectId"] = (input) =>
237 listProjectionThreadRows(input).pipe(
238 Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.listByProjectId:query")),
239 );
240
241 const deleteById: ProjectionThreadRepositoryShape["deleteById"] = (input) =>
242 deleteProjectionThreadRow(input).pipe(
243 Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.deleteById:query")),
244 );
245
246182 return {
247183 upsert,
248184 getById,
249 listByProjectId,
250 deleteById,
251185 } satisfies ProjectionThreadRepositoryShape;
252186});
253187
apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts+1-1
......@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
66import { runMigrations } from "../Migrations.ts";
77import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
1010
1111layer("016_CanonicalizeModelSelections", (it) => {
1212 it.effect(
apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts+1-1
......@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
66import { runMigrations } from "../Migrations.ts";
77import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
1010
1111layer("019_ProjectionSnapshotLookupIndexes", (it) => {
1212 it.effect("creates indexes for targeted projection lookup filters", () =>
apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts+1-1
......@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
66import { runMigrations } from "../Migrations.ts";
77import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
1010
1111layer("024_BackfillProjectionThreadShellSummary", (it) => {
1212 it.effect("backfills thread shell summary fields and clears stale projected approvals", () =>
apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts+1-1
......@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
66import { runMigrations } from "../Migrations.ts";
77import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
1010
1111layer("025_CleanupInvalidProjectionPendingApprovals", (it) => {
1212 it.effect("removes pending-approval rows that do not come from approval requests", () =>
apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts+1-1
......@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
66import { runMigrations } from "../Migrations.ts";
77import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
1010
1111layer("026_CanonicalizeModelSelectionOptions", (it) => {
1212 it.effect("converts legacy object-shape options into array-shape on projections and events", () =>
apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts+1-1
......@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
66import { runMigrations } from "../Migrations.ts";
77import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
1010
1111layer("027_028_ProviderInstanceIdColumns", (it) => {
1212 it.effect("continues when provider_session_runtime was partially migrated", () =>
apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts+1-1
......@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
66import { runMigrations } from "../Migrations.ts";
77import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
1010
1111layer("029_ProjectionThreadDetailOrderingIndexes", (it) => {
1212 it.effect("creates indexes matching thread detail ordering queries", () =>
apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.test.ts+1-1
......@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
66import { runMigrations } from "../Migrations.ts";
77import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
1010
1111layer("031_AuthAuthorizationScopes", (it) => {
1212 it.effect("invalidates role-based auth records and installs scoped auth tables", () =>
apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts+1-1
......@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
66import { runMigrations } from "../Migrations.ts";
77import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
1010
1111layer("035_ProjectionThreadTitleRegeneration", (it) => {
1212 it.effect("adds pending title regeneration columns", () =>
apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts+1-1
......@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
66import { runMigrations } from "../Migrations.ts";
77import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
1010
1111layer("040_ProjectionProjectFaviconPath", (it) => {
1212 it.effect("adds the nullable favicon path to project projections", () =>
apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts+1-1
......@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
66import { runMigrations } from "../Migrations.ts";
77import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
1010
1111layer("041_AuthSessionClientConnection", (it) => {
1212 it.effect("adds nullable client surface and app version columns to auth sessions", () =>
apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts+1-1
......@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
66import { runMigrations } from "../Migrations.ts";
77import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
1010
1111layer("042_ProjectionThreadLinkedPullRequest", (it) => {
1212 it.effect("adds the linked pull request column", () =>
apps/server/src/persistence/Migrations/046_RepairAutomaticSettlementTimestamps.test.ts+1-1
......@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
66import { runMigrations } from "../Migrations.ts";
77import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
1010
1111const MODEL_SELECTION = '{"instanceId":"codex","model":"gpt-5.6-sol"}';
1212
apps/server/src/persistence/Migrations/047_ProjectionProjectIcon.test.ts+1-1
......@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
66import { runMigrations } from "../Migrations.ts";
77import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
1010
1111layer("047_ProjectionProjectIcon", (it) => {
1212 it.effect("adds the nullable project icon JSON to project projections", () =>
apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.test.ts+26-23
......@@ -6,13 +6,15 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
66import { runMigrations } from "../Migrations.ts";
77import migrateActiveOrderKey from "./049_ProjectionThreadsActiveOrderKey.ts";
88
9it.layer(NodeSqliteClient.layerMemory())("049_ProjectionThreadsActiveOrderKey", (it) => {
10 it.effect("migrates old threads without changing their timestamps or assigning an order", () =>
11 Effect.gen(function* () {
12 const sql = yield* SqlClient.SqlClient;
13 yield* runMigrations({ toMigrationInclusive: 48 });
14 const now = "2026-01-01T00:00:00.000Z";
15 yield* sql`
9it.layer(NodeSqliteClient.layer({ filename: ":memory:" }))(
10 "049_ProjectionThreadsActiveOrderKey",
11 (it) => {
12 it.effect("migrates old threads without changing their timestamps or assigning an order", () =>
13 Effect.gen(function* () {
14 const sql = yield* SqlClient.SqlClient;
15 yield* runMigrations({ toMigrationInclusive: 48 });
16 const now = "2026-01-01T00:00:00.000Z";
17 yield* sql`
1618 INSERT INTO projection_threads (
1719 thread_id, project_id, title, model_selection_json, runtime_mode,
1820 created_at, updated_at
......@@ -21,24 +23,25 @@ it.layer(NodeSqliteClient.layerMemory())("049_ProjectionThreadsActiveOrderKey",
2123 '{"instanceId":"codex","model":"gpt-5.4"}', 'full-access', ${now}, ${now}
2224 )
2325 `;
24 yield* runMigrations({ toMigrationInclusive: 49 });
25 const migrated = yield* sql<{ readonly activeOrderKey: string | null }>`
26 yield* runMigrations({ toMigrationInclusive: 49 });
27 const migrated = yield* sql<{ readonly activeOrderKey: string | null }>`
2628 SELECT active_order_key AS "activeOrderKey" FROM projection_threads WHERE thread_id = 'thread-1'
2729 `;
28 assert.deepEqual(migrated, [{ activeOrderKey: null }]);
29 // Recovery may run the same migration against a database that already
30 // has the column, including a placement written after the upgrade.
31 yield* sql`UPDATE projection_threads SET active_order_key = 'gm' WHERE thread_id = 'thread-1'`;
32 yield* migrateActiveOrderKey;
33 const rows = yield* sql<{
34 readonly activeOrderKey: string | null;
35 readonly createdAt: string;
36 readonly updatedAt: string;
37 }>`
30 assert.deepEqual(migrated, [{ activeOrderKey: null }]);
31 // Recovery may run the same migration against a database that already
32 // has the column, including a placement written after the upgrade.
33 yield* sql`UPDATE projection_threads SET active_order_key = 'gm' WHERE thread_id = 'thread-1'`;
34 yield* migrateActiveOrderKey;
35 const rows = yield* sql<{
36 readonly activeOrderKey: string | null;
37 readonly createdAt: string;
38 readonly updatedAt: string;
39 }>`
3840 SELECT active_order_key AS "activeOrderKey", created_at AS "createdAt", updated_at AS "updatedAt"
3941 FROM projection_threads WHERE thread_id = 'thread-1'
4042 `;
41 assert.deepEqual(rows, [{ activeOrderKey: "gm", createdAt: now, updatedAt: now }]);
42 }),
43 );
44});
43 assert.deepEqual(rows, [{ activeOrderKey: "gm", createdAt: now, updatedAt: now }]);
44 }),
45 );
46 },
47);
apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.test.ts+22-19
......@@ -9,7 +9,7 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
99
1010const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
1111
12const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
12const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
1313
1414interface PullRequestRow {
1515 readonly threadId: string;
......@@ -156,26 +156,29 @@ layer("050_ProjectionThreadPullRequests", (it) => {
156156 );
157157});
158158
159it.layer(Layer.fresh(NodeSqliteClient.layerMemory()))("050 Azure legacy links", (it) => {
160 it.effect("keeps legacy Azure repositories distinct across organizations", () =>
161 Effect.gen(function* () {
162 const sql = yield* SqlClient.SqlClient;
163 yield* runMigrations({ toMigrationInclusive: 49 });
164 for (const organization of ["org-a", "org-b"]) {
165 yield* sql`
159it.layer(Layer.fresh(NodeSqliteClient.layer({ filename: ":memory:" })))(
160 "050 Azure legacy links",
161 (it) => {
162 it.effect("keeps legacy Azure repositories distinct across organizations", () =>
163 Effect.gen(function* () {
164 const sql = yield* SqlClient.SqlClient;
165 yield* runMigrations({ toMigrationInclusive: 49 });
166 for (const organization of ["org-a", "org-b"]) {
167 yield* sql`
166168 INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, linked_pull_request_json, created_at, updated_at)
167169 VALUES (${organization}, ${organization}, 'Azure', '{"instanceId":"codex","model":"gpt-5.4"}',
168170 ${encodeJson({ projectId: organization, repository: "web", number: 7, url: `https://dev.azure.com/${organization}/project/_git/web/pullrequest/7` })},
169171 '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z')
170172 `;
171 }
172 yield* runMigrations({ toMigrationInclusive: 50 });
173 const rows =
174 yield* sql`SELECT host, repository, number FROM projection_thread_pull_requests ORDER BY repository`;
175 assert.deepStrictEqual(rows, [
176 { host: "dev.azure.com", repository: "org-a/project/_git/web", number: 7 },
177 { host: "dev.azure.com", repository: "org-b/project/_git/web", number: 7 },
178 ]);
179 }),
180 );
181});
173 }
174 yield* runMigrations({ toMigrationInclusive: 50 });
175 const rows =
176 yield* sql`SELECT host, repository, number FROM projection_thread_pull_requests ORDER BY repository`;
177 assert.deepStrictEqual(rows, [
178 { host: "dev.azure.com", repository: "org-a/project/_git/web", number: 7 },
179 { host: "dev.azure.com", repository: "org-b/project/_git/web", number: 7 },
180 ]);
181 }),
182 );
183 },
184);
apps/server/src/persistence/Migrations/051_ProjectionThreadMessageContext.test.ts+1-1
......@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
66import { runMigrations } from "../Migrations.ts";
77import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
1010
1111layer("051_ProjectionThreadMessageContext", (it) => {
1212 it.effect("accepts context added by an earlier development migration", () =>
apps/server/src/persistence/Services/ProjectionCheckpoints.ts deleted-95
......@@ -1,95 +0,0 @@
1/**
2 * ProjectionCheckpointRepository - Projection repository interface for checkpoints.
3 *
4 * Owns persistence operations for projected checkpoint summaries in thread
5 * timelines.
6 *
7 * @module ProjectionCheckpointRepository
8 */
9import {
10 CheckpointRef,
11 IsoDateTime,
12 MessageId,
13 NonNegativeInt,
14 OrchestrationCheckpointFile,
15 OrchestrationCheckpointStatus,
16 ThreadId,
17 TurnId,
18} from "@t3tools/contracts";
19import * as Option from "effect/Option";
20import * as Context from "effect/Context";
21import * as Schema from "effect/Schema";
22import type * as Effect from "effect/Effect";
23
24import type { ProjectionRepositoryError } from "../Errors.ts";
25
26export const ProjectionCheckpoint = Schema.Struct({
27 threadId: ThreadId,
28 turnId: TurnId,
29 checkpointTurnCount: NonNegativeInt,
30 checkpointRef: CheckpointRef,
31 status: OrchestrationCheckpointStatus,
32 files: Schema.Array(OrchestrationCheckpointFile),
33 assistantMessageId: Schema.NullOr(MessageId),
34 completedAt: IsoDateTime,
35});
36export type ProjectionCheckpoint = typeof ProjectionCheckpoint.Type;
37
38export const ListByThreadIdInput = Schema.Struct({
39 threadId: ThreadId,
40});
41export type ListByThreadIdInput = typeof ListByThreadIdInput.Type;
42
43export const GetByThreadAndTurnCountInput = Schema.Struct({
44 threadId: ThreadId,
45 checkpointTurnCount: NonNegativeInt,
46});
47export type GetByThreadAndTurnCountInput = typeof GetByThreadAndTurnCountInput.Type;
48
49export const DeleteByThreadIdInput = Schema.Struct({
50 threadId: ThreadId,
51});
52export type DeleteByThreadIdInput = typeof DeleteByThreadIdInput.Type;
53
54/**
55 * ProjectionCheckpointRepositoryShape - Service API for projected checkpoints.
56 */
57export interface ProjectionCheckpointRepositoryShape {
58 /**
59 * Insert or replace a projected checkpoint row.
60 *
61 * Upserts by composite key `(threadId, checkpointTurnCount)`.
62 */
63 readonly upsert: (row: ProjectionCheckpoint) => Effect.Effect<void, ProjectionRepositoryError>;
64
65 /**
66 * List projected checkpoints for a thread.
67 *
68 * Returned in ascending checkpoint turn-count order.
69 */
70 readonly listByThreadId: (
71 input: ListByThreadIdInput,
72 ) => Effect.Effect<ReadonlyArray<ProjectionCheckpoint>, ProjectionRepositoryError>;
73
74 /**
75 * Read a projected checkpoint by thread and turn-count key.
76 */
77 readonly getByThreadAndTurnCount: (
78 input: GetByThreadAndTurnCountInput,
79 ) => Effect.Effect<Option.Option<ProjectionCheckpoint>, ProjectionRepositoryError>;
80
81 /**
82 * Delete projected checkpoint rows by thread.
83 */
84 readonly deleteByThreadId: (
85 input: DeleteByThreadIdInput,
86 ) => Effect.Effect<void, ProjectionRepositoryError>;
87}
88
89/**
90 * ProjectionCheckpointRepository - Service tag for checkpoint projection persistence.
91 */
92export class ProjectionCheckpointRepository extends Context.Service<
93 ProjectionCheckpointRepository,
94 ProjectionCheckpointRepositoryShape
95>()("t3/persistence/Services/ProjectionCheckpoints/ProjectionCheckpointRepository") {}
apps/server/src/persistence/Services/ProjectionPendingApprovals.ts-12
......@@ -42,11 +42,6 @@ export const GetProjectionPendingApprovalInput = Schema.Struct({
4242});
4343export type GetProjectionPendingApprovalInput = typeof GetProjectionPendingApprovalInput.Type;
4444
45export const DeleteProjectionPendingApprovalInput = Schema.Struct({
46 requestId: ApprovalRequestId,
47});
48export type DeleteProjectionPendingApprovalInput = typeof DeleteProjectionPendingApprovalInput.Type;
49
5045/**
5146 * ProjectionPendingApprovalRepositoryShape - Service API for pending approvals.
5247 */
......@@ -81,13 +76,6 @@ export interface ProjectionPendingApprovalRepositoryShape {
8176 input: GetProjectionPendingApprovalInput,
8277 ) => Effect.Effect<Option.Option<ProjectionPendingApproval>, ProjectionRepositoryError>;
8378
84 /**
85 * Delete a pending approval row by request id.
86 */
87 readonly deleteByRequestId: (
88 input: DeleteProjectionPendingApprovalInput,
89 ) => Effect.Effect<void, ProjectionRepositoryError>;
90
9179 /**
9280 * Delete every pending approval row for a thread.
9381 */
apps/server/src/persistence/Services/ProjectionProjects.ts-22
......@@ -42,11 +42,6 @@ export const GetProjectionProjectInput = Schema.Struct({
4242});
4343export type GetProjectionProjectInput = typeof GetProjectionProjectInput.Type;
4444
45export const DeleteProjectionProjectInput = Schema.Struct({
46 projectId: ProjectId,
47});
48export type DeleteProjectionProjectInput = typeof DeleteProjectionProjectInput.Type;
49
5045/**
5146 * ProjectionProjectRepositoryShape - Service API for projected project records.
5247 */
......@@ -64,23 +59,6 @@ export interface ProjectionProjectRepositoryShape {
6459 readonly getById: (
6560 input: GetProjectionProjectInput,
6661 ) => Effect.Effect<Option.Option<ProjectionProject>, ProjectionRepositoryError>;
67
68 /**
69 * List all projected project rows.
70 *
71 * Returned in deterministic creation order.
72 */
73 readonly listAll: () => Effect.Effect<
74 ReadonlyArray<ProjectionProject>,
75 ProjectionRepositoryError
76 >;
77
78 /**
79 * Soft-delete a projected project row by id.
80 */
81 readonly deleteById: (
82 input: DeleteProjectionProjectInput,
83 ) => Effect.Effect<void, ProjectionRepositoryError>;
8462}
8563
8664/**
apps/server/src/persistence/Services/ProjectionState.ts-7
......@@ -53,13 +53,6 @@ export interface ProjectionStateRepositoryShape {
5353 * List all projector cursor rows.
5454 */
5555 readonly listAll: () => Effect.Effect<ReadonlyArray<ProjectionState>, ProjectionRepositoryError>;
56
57 /**
58 * Read the minimum applied sequence across all projectors.
59 *
60 * Returns `null` when no projector state rows exist.
61 */
62 readonly minLastAppliedSequence: () => Effect.Effect<number | null, ProjectionRepositoryError>;
6356}
6457
6558/**
apps/server/src/persistence/Services/ProjectionThreads.ts-26
......@@ -65,16 +65,6 @@ export const GetProjectionThreadInput = Schema.Struct({
6565});
6666export type GetProjectionThreadInput = typeof GetProjectionThreadInput.Type;
6767
68export const DeleteProjectionThreadInput = Schema.Struct({
69 threadId: ThreadId,
70});
71export type DeleteProjectionThreadInput = typeof DeleteProjectionThreadInput.Type;
72
73export const ListProjectionThreadsByProjectInput = Schema.Struct({
74 projectId: ProjectId,
75});
76export type ListProjectionThreadsByProjectInput = typeof ListProjectionThreadsByProjectInput.Type;
77
7868/**
7969 * ProjectionThreadRepositoryShape - Service API for projected thread records.
8070 */
......@@ -92,22 +82,6 @@ export interface ProjectionThreadRepositoryShape {
9282 readonly getById: (
9383 input: GetProjectionThreadInput,
9484 ) => Effect.Effect<Option.Option<ProjectionThread>, ProjectionRepositoryError>;
95
96 /**
97 * List projected threads for a project.
98 *
99 * Returned in deterministic creation order.
100 */
101 readonly listByProjectId: (
102 input: ListProjectionThreadsByProjectInput,
103 ) => Effect.Effect<ReadonlyArray<ProjectionThread>, ProjectionRepositoryError>;
104
105 /**
106 * Soft-delete a projected thread row by id.
107 */
108 readonly deleteById: (
109 input: DeleteProjectionThreadInput,
110 ) => Effect.Effect<void, ProjectionRepositoryError>;
11185}
11286
11387/**
apps/server/src/provider/acp/XAiAcpExtension.ts-4
......@@ -110,10 +110,6 @@ interface XAiAskUserQuestionCancelledResponse {
110110 readonly outcome: "cancelled";
111111}
112112
113export type XAiAskUserQuestionResponse =
114 | XAiAskUserQuestionAcceptedResponse
115 | XAiAskUserQuestionCancelledResponse;
116
117113interface NormalizedXAiAnswer {
118114 readonly questionText: string;
119115 readonly selectedLabels: ReadonlyArray<string>;
apps/server/src/server.test.ts+9-12
......@@ -123,10 +123,7 @@ import * as Keybindings from "./keybindings.ts";
123123import * as ExternalLauncher from "./process/externalLauncher.ts";
124124import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
125125import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
126import {
127 OrchestrationListenerCallbackError,
128 OrchestrationThreadSettleBlockedError,
129} from "./orchestration/Errors.ts";
126import { OrchestrationThreadSettleBlockedError } from "./orchestration/Errors.ts";
130127import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
131128import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts";
132129import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts";
......@@ -7358,8 +7355,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
73587355 Effect.flatMap(() =>
73597356 command.commandId === failedCommandId
73607357 ? Effect.fail(
7361 new OrchestrationListenerCallbackError({
7362 listener: "domain-event",
7358 new PersistenceSqlError({
7359 operation: "OrchestrationEventStore.append:query",
73637360 detail: "thread creation failed",
73647361 }),
73657362 )
......@@ -10682,8 +10679,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1068210679 effects.push(`dispatch:${command.type}`);
1068310680 if (command.type === "thread.session.stop") {
1068410681 return Effect.fail(
10685 new OrchestrationListenerCallbackError({
10686 listener: "domain-event",
10682 new PersistenceSqlError({
10683 operation: "OrchestrationEventStore.append:query",
1068710684 detail: "simulated archive stop failure",
1068810685 }),
1068910686 );
......@@ -11629,8 +11626,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1162911626 setupActivityAppendAttempt += 1;
1163011627 if (setupActivityAppendAttempt === 2) {
1163111628 return Effect.fail(
11632 new OrchestrationListenerCallbackError({
11633 listener: "domain-event",
11629 new PersistenceSqlError({
11630 operation: "OrchestrationEventStore.append:query",
1163411631 detail: "failed to append setup-script.started activity",
1163511632 }),
1163611633 );
......@@ -12155,8 +12152,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1215512152 dispatchedCommands.push(command);
1215612153 if (command.type === "thread.delete") {
1215712154 return Effect.fail(
12158 new OrchestrationListenerCallbackError({
12159 listener: "domain-event",
12155 new PersistenceSqlError({
12156 operation: "OrchestrationEventStore.append:query",
1216012157 detail: "thread cleanup exploded",
1216112158 }),
1216212159 );
apps/server/src/textGeneration/TextGeneration.ts-2
......@@ -10,8 +10,6 @@ import * as SourceControlProviderRegistry from "../sourceControl/SourceControlPr
1010import * as ThreadTitleLinks from "./ThreadTitleLinks.ts";
1111import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts";
1212
13export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode";
14
1513export interface CommitMessageGenerationInput {
1614 cwd: string;
1715 branch: string | null;
apps/server/src/workspace/WorkspaceSearchIndex.ts-6
......@@ -107,12 +107,6 @@ export class WorkspaceSearchIndexDestroyFailed extends Schema.TaggedError<Worksp
107107 }
108108}
109109
110export type WorkspaceSearchIndexError =
111 | WorkspaceSearchIndexCreateFailed
112 | WorkspaceSearchIndexScanTimedOut
113 | WorkspaceSearchIndexSearchFailed
114 | WorkspaceSearchIndexRefreshFailed;
115
116110export class WorkspaceSearchIndex extends Context.Service<
117111 WorkspaceSearchIndex,
118112 {
apps/web/src/cloud/managedAuth.test.ts+6-3
......@@ -1,11 +1,11 @@
11import { managedRelaySessionAtom, setManagedRelaySession } from "@t3tools/client-runtime/relay";
2import * as Effect from "effect/Effect";
23import { afterEach, describe, expect, it, vi } from "vite-plus/test";
34
45import { appAtomRegistry } from "../rpc/atomRegistry";
56import {
67 activateManagedRelayAuthentication,
78 deactivateManagedRelayAuthentication,
8 readManagedRelayClerkToken,
99} from "./managedAuth";
1010
1111vi.mock("@clerk/react", () => ({
......@@ -32,13 +32,16 @@ describe("managed relay authentication", () => {
3232 it("clears all token access synchronously before account cleanup can fail", async () => {
3333 activateManagedRelayAuthentication("account-1", async () => "account-1-token");
3434 expect(appAtomRegistry.get(managedRelaySessionAtom)?.accountId).toBe("account-1");
35 expect(await readManagedRelayClerkToken()).toBe("account-1-token");
35 const token = await Effect.fromNullishOr(appAtomRegistry.get(managedRelaySessionAtom)).pipe(
36 Effect.flatMap((session) => session.readClerkToken()),
37 Effect.runPromise,
38 );
39 expect(token).toBe("account-1-token");
3640
3741 deactivateManagedRelayAuthentication();
3842 const cleanup = Promise.reject(new Error("Persistence removal failed.")).catch(() => undefined);
3943
4044 expect(appAtomRegistry.get(managedRelaySessionAtom)).toBeNull();
41 expect(await readManagedRelayClerkToken()).toBeNull();
4245 await cleanup;
4346 });
4447
apps/web/src/cloud/managedAuth.tsx-8
......@@ -14,14 +14,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry";
1414import { useAtomCommand } from "../state/use-atom-command";
1515import { resolveRelayClerkTokenOptions } from "./publicConfig";
1616
17let relayTokenProvider: (() => Promise<string | null>) | null = null;
18
19export async function readManagedRelayClerkToken(): Promise<string | null> {
20 return relayTokenProvider?.() ?? null;
21}
22
2317export function deactivateManagedRelayAuthentication(): void {
24 relayTokenProvider = null;
2518 setManagedRelaySession(appAtomRegistry, null);
2619}
2720
......@@ -29,7 +22,6 @@ export function activateManagedRelayAuthentication(
2922 accountId: string,
3023 readClerkToken: () => Promise<string | null>,
3124): void {
32 relayTokenProvider = readClerkToken;
3325 setManagedRelaySession(appAtomRegistry, {
3426 accountId,
3527 readClerkToken,
apps/web/src/components/ChatView.logic.test.ts-46
......@@ -32,7 +32,6 @@ import {
3232 usePreviewMiniPlayerStore,
3333} from "../previewMiniPlayerStore";
3434import {
35 MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
3635 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
3736 agentControlledBrowserCloseConfirmation,
3837 branchMismatchKey,
......@@ -52,7 +51,6 @@ import {
5251 shouldRefocusComposerOnWindowFocus,
5352 isBranchMismatchDismissedForSession,
5453 reconcileMountedTerminalThreadIds,
55 reconcileRetainedMountedThreadIds,
5654 recallCheckoutIsRepo,
5755 rememberCheckoutIsRepo,
5856 resolveBackgroundDraftWorkspaceOptions,
......@@ -1887,50 +1885,6 @@ describe("reconcileMountedTerminalThreadIds", () => {
18871885 });
18881886});
18891887
1890describe("reconcileRetainedMountedThreadIds", () => {
1891 it("retains hidden open threads and adds the active open thread", () => {
1892 expect(
1893 reconcileRetainedMountedThreadIds({
1894 currentThreadIds: [ThreadId.make("thread-hidden")],
1895 openThreadIds: [ThreadId.make("thread-hidden")],
1896 activeThreadId: ThreadId.make("thread-active"),
1897 activeThreadOpen: true,
1898 maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
1899 }),
1900 ).toEqual([ThreadId.make("thread-hidden"), ThreadId.make("thread-active")]);
1901 });
1902
1903 it("can retain the active thread as hidden when it is inactive", () => {
1904 expect(
1905 reconcileRetainedMountedThreadIds({
1906 currentThreadIds: [ThreadId.make("thread-active")],
1907 openThreadIds: [ThreadId.make("thread-active")],
1908 activeThreadId: ThreadId.make("thread-active"),
1909 activeThreadOpen: false,
1910 maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
1911 retainInactiveActiveThread: true,
1912 }),
1913 ).toEqual([ThreadId.make("thread-active")]);
1914 });
1915
1916 it("evicts the oldest hidden threads beyond the configured cap", () => {
1917 const currentThreadIds = Array.from(
1918 { length: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS + 2 },
1919 (_, index) => ThreadId.make(`thread-${index + 1}`),
1920 );
1921
1922 expect(
1923 reconcileRetainedMountedThreadIds({
1924 currentThreadIds,
1925 openThreadIds: currentThreadIds,
1926 activeThreadId: null,
1927 activeThreadOpen: false,
1928 maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
1929 }),
1930 ).toEqual(currentThreadIds.slice(-MAX_HIDDEN_MOUNTED_PREVIEW_THREADS));
1931 });
1932});
1933
19341888describe("shouldWriteThreadErrorToCurrentServerThread", () => {
19351889 it("writes errors for a shell-derived active server thread", () => {
19361890 const routeThreadRef = { environmentId, threadId };
apps/web/src/components/ChatView.logic.ts+7-23
......@@ -60,7 +60,7 @@ import {
6060
6161export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
6262export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
63export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3;
63
6464export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000;
6565
6666export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
......@@ -678,31 +678,15 @@ export function reconcileMountedTerminalThreadIds(input: {
678678 activeThreadId: string | null;
679679 activeThreadTerminalOpen: boolean;
680680 maxHiddenThreadCount?: number;
681}): string[] {
682 return reconcileRetainedMountedThreadIds({
683 currentThreadIds: input.currentThreadIds,
684 openThreadIds: input.openThreadIds,
685 activeThreadId: input.activeThreadId,
686 activeThreadOpen: input.activeThreadTerminalOpen,
687 maxHiddenThreadCount: input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
688 });
689}
690
691export function reconcileRetainedMountedThreadIds(input: {
692 currentThreadIds: ReadonlyArray<string>;
693 openThreadIds: ReadonlyArray<string>;
694 activeThreadId: string | null;
695 activeThreadOpen: boolean;
696 maxHiddenThreadCount: number;
697 retainInactiveActiveThread?: boolean;
698681}): string[] {
699682 const openThreadIdSet = new Set(input.openThreadIds);
700683 const hiddenThreadIds = input.currentThreadIds.filter(
701 (threadId) =>
702 (threadId !== input.activeThreadId || input.retainInactiveActiveThread === true) &&
703 openThreadIdSet.has(threadId),
684 (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId),
685 );
686 const maxHiddenThreadCount = Math.max(
687 0,
688 input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
704689 );
705 const maxHiddenThreadCount = Math.max(0, input.maxHiddenThreadCount);
706690 const nextThreadIds =
707691 hiddenThreadIds.length > maxHiddenThreadCount
708692 ? hiddenThreadIds.slice(-maxHiddenThreadCount)
......@@ -710,7 +694,7 @@ export function reconcileRetainedMountedThreadIds(input: {
710694
711695 if (
712696 input.activeThreadId &&
713 input.activeThreadOpen &&
697 input.activeThreadTerminalOpen &&
714698 !nextThreadIds.includes(input.activeThreadId)
715699 ) {
716700 nextThreadIds.push(input.activeThreadId);
apps/web/src/components/preview/PreviewPanelShell.test.ts+1-11
......@@ -1,8 +1,6 @@
1import { jsx } from "react/jsx-runtime";
2import { renderToStaticMarkup } from "react-dom/server";
31import { describe, expect, it } from "vite-plus/test";
42
5import { getPreviewPanelMaxWidth, PreviewPanelShell } from "./PreviewPanelShell";
3import { getPreviewPanelMaxWidth } from "./PreviewPanelShell";
64
75describe("getPreviewPanelMaxWidth", () => {
86 it("allows the panel to use 70% of an ultra-wide viewport without a pixel ceiling", () => {
......@@ -13,14 +11,6 @@ describe("getPreviewPanelMaxWidth", () => {
1311 expect(getPreviewPanelMaxWidth(2_001)).toBe(1_400);
1412 });
1513
16 it("keeps inline panels inside their containing workspace", () => {
17 const markup = renderToStaticMarkup(
18 jsx(PreviewPanelShell, { mode: "inline", defaultWidth: 1_000, children: "Panel" }),
19 );
20
21 expect(markup).toContain("max-w-full");
22 });
23
2414 it("reserves the sibling column minimum when the flex row is known", () => {
2515 // Fullscreen 14" MacBook: viewport 1512, sidebar ~256 → row of 1256.
2616 // The 70% fraction (1058) would leave the chat column only ~198px;
apps/web/src/components/settings/settingsLayout.test.tsx+1-19
......@@ -1,12 +1,7 @@
11import { renderToStaticMarkup } from "react-dom/server";
22import { afterEach, describe, expect, it, vi } from "vite-plus/test";
33
4import {
5 scrollToSettingsTarget,
6 SettingsRow,
7 SettingsSearchTargetProvider,
8 SettingsUnavailableGroup,
9} from "./settingsLayout";
4import { scrollToSettingsTarget, SettingsRow, SettingsUnavailableGroup } from "./settingsLayout";
105
116afterEach(() => {
127 vi.unstubAllGlobals();
......@@ -27,19 +22,6 @@ describe("unavailable settings", () => {
2722});
2823
2924describe("settings search targets", () => {
30 it("does not persist destination styling in the rendered row", () => {
31 const markup = renderToStaticMarkup(
32 <SettingsSearchTargetProvider targetId="word-wrap">
33 <SettingsRow id="word-wrap" title="Word wrap" description="Wrap long lines." />
34 <SettingsRow id="time-format" title="Time format" description="Choose a clock." />
35 </SettingsSearchTargetProvider>,
36 );
37
38 expect(markup).toContain('id="word-wrap" tabindex="-1"');
39 expect(markup).not.toContain("data-settings-search-target");
40 expect(markup).not.toContain("settings-search-target-pulse");
41 });
42
4325 it("scrolls directly to a section header and restarts the destination pulse", () => {
4426 const sectionScrollIntoView = vi.fn();
4527 const headerScrollIntoView = vi.fn();
apps/web/src/components/settings/settingsLayout.tsx+1-1
......@@ -57,7 +57,7 @@ const SettingsSearchTargetContext = createContext<SettingsSearchTargetContextVal
5757 onTargetHandled: noop,
5858});
5959
60export function SettingsSearchTargetProvider({
60function SettingsSearchTargetProvider({
6161 targetId,
6262 highlightTarget = true,
6363 onTargetHandled = noop,
apps/web/src/components/ui/alert-dialog.tsx-8
......@@ -9,16 +9,10 @@ import {
99 DIALOG_POPUP_CLASS,
1010} from "~/components/ui/dialog-styles";
1111
12const AlertDialogCreateHandle = AlertDialogPrimitive.createHandle;
13
1412const AlertDialog = AlertDialogPrimitive.Root;
1513
1614const AlertDialogPortal = AlertDialogPrimitive.Portal;
1715
18function AlertDialogTrigger(props: AlertDialogPrimitive.Trigger.Props) {
19 return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
20}
21
2216function AlertDialogBackdrop({ className, ...props }: AlertDialogPrimitive.Backdrop.Props) {
2317 return (
2418 <AlertDialogPrimitive.Backdrop
......@@ -129,12 +123,10 @@ function AlertDialogClose(props: AlertDialogPrimitive.Close.Props) {
129123}
130124
131125export {
132 AlertDialogCreateHandle,
133126 AlertDialog,
134127 AlertDialogPortal,
135128 AlertDialogBackdrop,
136129 AlertDialogBackdrop as AlertDialogOverlay,
137 AlertDialogTrigger,
138130 AlertDialogPopup,
139131 AlertDialogPopup as AlertDialogContent,
140132 AlertDialogHeader,
apps/web/src/components/ui/autocomplete.tsx-40
......@@ -136,16 +136,6 @@ function AutocompleteItem({ className, children, ...props }: AutocompletePrimiti
136136 );
137137}
138138
139function AutocompleteSeparator({ className, ...props }: AutocompletePrimitive.Separator.Props) {
140 return (
141 <AutocompletePrimitive.Separator
142 className={cn("mx-2 my-1 h-px bg-border last:hidden", className)}
143 data-slot="autocomplete-separator"
144 {...props}
145 />
146 );
147}
148
149139function AutocompleteGroup({ className, ...props }: AutocompletePrimitive.Group.Props) {
150140 return (
151141 <AutocompletePrimitive.Group
......@@ -179,16 +169,6 @@ function AutocompleteEmpty({ className, ...props }: AutocompletePrimitive.Empty.
179169 );
180170}
181171
182function AutocompleteRow({ className, ...props }: AutocompletePrimitive.Row.Props) {
183 return (
184 <AutocompletePrimitive.Row className={className} data-slot="autocomplete-row" {...props} />
185 );
186}
187
188function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) {
189 return <AutocompletePrimitive.Value data-slot="autocomplete-value" {...props} />;
190}
191
192172function AutocompleteList({ className, ...props }: AutocompletePrimitive.List.Props) {
193173 return (
194174 <ScrollArea scrollbarGutter scrollFade>
......@@ -216,19 +196,6 @@ function AutocompleteClear({ className, ...props }: AutocompletePrimitive.Clear.
216196 );
217197}
218198
219function AutocompleteStatus({ className, ...props }: AutocompletePrimitive.Status.Props) {
220 return (
221 <AutocompletePrimitive.Status
222 className={cn(
223 "px-3 py-2 font-medium text-muted-foreground text-xs empty:m-0 empty:p-0",
224 className,
225 )}
226 data-slot="autocomplete-status"
227 {...props}
228 />
229 );
230}
231
232199function AutocompleteCollection({ ...props }: AutocompletePrimitive.Collection.Props) {
233200 return <AutocompletePrimitive.Collection data-slot="autocomplete-collection" {...props} />;
234201}
......@@ -249,23 +216,16 @@ function AutocompleteTrigger({
249216 );
250217}
251218
252const useAutocompleteFilter = AutocompletePrimitive.useFilter;
253
254219export {
255220 Autocomplete,
256221 AutocompleteInput,
257222 AutocompleteTrigger,
258223 AutocompletePopup,
259224 AutocompleteItem,
260 AutocompleteSeparator,
261225 AutocompleteGroup,
262226 AutocompleteGroupLabel,
263227 AutocompleteEmpty,
264 AutocompleteValue,
265228 AutocompleteList,
266229 AutocompleteClear,
267 AutocompleteStatus,
268 AutocompleteRow,
269230 AutocompleteCollection,
270 useAutocompleteFilter,
271231};
apps/web/src/components/ui/combobox.tsx-146
......@@ -28,31 +28,6 @@ function Combobox<Value, Multiple extends boolean | undefined = false>(
2828 );
2929}
3030
31function ComboboxChipsInput({
32 className,
33 size,
34 ...props
35}: Omit<ComboboxPrimitive.Input.Props, "size"> & {
36 size?: "sm" | "default" | "lg" | number;
37 ref?: React.Ref<HTMLInputElement>;
38}) {
39 const sizeValue = (size ?? "default") as "sm" | "default" | "lg" | number;
40
41 return (
42 <ComboboxPrimitive.Input
43 className={cn(
44 "min-w-12 flex-1 text-base outline-none sm:text-sm [[data-slot=combobox-chip]+&]:ps-0.5",
45 sizeValue === "sm" ? "ps-1.5" : "ps-2",
46 className,
47 )}
48 data-size={typeof sizeValue === "string" ? sizeValue : undefined}
49 data-slot="combobox-chips-input"
50 size={typeof sizeValue === "number" ? sizeValue : undefined}
51 {...props}
52 />
53 );
54}
55
5631function ComboboxInput({
5732 className,
5833 inputClassName,
......@@ -240,36 +215,6 @@ function ComboboxItem({
240215 );
241216}
242217
243function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator.Props) {
244 return (
245 <ComboboxPrimitive.Separator
246 className={cn("mx-2 my-1 h-px bg-border last:hidden", className)}
247 data-slot="combobox-separator"
248 {...props}
249 />
250 );
251}
252
253function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
254 return (
255 <ComboboxPrimitive.Group
256 className={cn("[[role=group]+&]:mt-1.5", className)}
257 data-slot="combobox-group"
258 {...props}
259 />
260 );
261}
262
263function ComboboxGroupLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) {
264 return (
265 <ComboboxPrimitive.GroupLabel
266 className={cn("px-2 py-1.5 font-medium text-muted-foreground text-xs", className)}
267 data-slot="combobox-group-label"
268 {...props}
269 />
270 );
271}
272
273218function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
274219 return (
275220 <ComboboxPrimitive.Empty
......@@ -283,14 +228,6 @@ function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
283228 );
284229}
285230
286function ComboboxRow({ className, ...props }: ComboboxPrimitive.Row.Props) {
287 return <ComboboxPrimitive.Row className={className} data-slot="combobox-row" {...props} />;
288}
289
290function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
291 return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
292}
293
294231function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
295232 return (
296233 <ScrollArea scrollbarGutter scrollFade>
......@@ -334,102 +271,19 @@ function ComboboxStatus({ className, ...props }: ComboboxPrimitive.Status.Props)
334271 );
335272}
336273
337function ComboboxCollection(props: ComboboxPrimitive.Collection.Props) {
338 return <ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />;
339}
340
341function ComboboxChips({
342 className,
343 children,
344 startAddon,
345 ...props
346}: ComboboxPrimitive.Chips.Props & {
347 startAddon?: React.ReactNode;
348}) {
349 const { chipsRef } = React.use(ComboboxContext);
350
351 return (
352 <ComboboxPrimitive.Chips
353 className={cn(
354 "relative inline-flex min-h-9 w-full flex-wrap gap-1 rounded-lg border border-input bg-background not-dark:bg-clip-padding p-[calc(--spacing(1)-1px)] text-base shadow-xs/5 outline-none ring-ring/24 transition-shadow *:min-h-7 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-disabled:not-focus-within:not-aria-invalid:before:shadow-[0_1px_--theme(--color-black/4%)] focus-within:border-ring focus-within:ring-[3px] has-disabled:pointer-events-none has-data-[size=lg]:min-h-10 has-data-[size=sm]:min-h-8 has-aria-invalid:border-destructive/36 has-autofill:bg-foreground/4 has-disabled:opacity-64 has-[:disabled,:focus-within,[aria-invalid]]:shadow-none focus-within:has-aria-invalid:border-destructive/64 focus-within:has-aria-invalid:ring-destructive/16 has-data-[size=lg]:*:min-h-8 has-data-[size=sm]:*:min-h-6 sm:min-h-8 sm:text-sm sm:has-data-[size=lg]:min-h-9 sm:has-data-[size=sm]:min-h-7 sm:*:min-h-6 sm:has-data-[size=lg]:*:min-h-7 sm:has-data-[size=sm]:*:min-h-5 dark:not-has-disabled:bg-input/32 dark:has-autofill:bg-foreground/8 dark:has-aria-invalid:ring-destructive/24 dark:not-has-disabled:not-focus-within:not-aria-invalid:before:shadow-[0_-1px_--theme(--color-white/6%)]",
355 className,
356 )}
357 data-slot="combobox-chips"
358 ref={chipsRef as React.Ref<HTMLDivElement> | null}
359 {...props}
360 >
361 {startAddon && (
362 <div
363 aria-hidden="true"
364 className="[&_svg]:-ms-0.5 [&_svg]:-me-1.5 flex shrink-0 items-center ps-2 opacity-80 has-[~[data-size=sm]]:has-[+[data-slot=combobox-chip]]:pe-1.5 has-[~[data-size=sm]]:ps-1.5 has-[+[data-slot=combobox-chip]]:pe-2 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none"
365 data-slot="combobox-start-addon"
366 >
367 {startAddon}
368 </div>
369 )}
370 {children}
371 </ComboboxPrimitive.Chips>
372 );
373}
374
375function ComboboxChip({ children, ...props }: ComboboxPrimitive.Chip.Props) {
376 const labelId = React.useId();
377
378 return (
379 <ComboboxPrimitive.Chip
380 className="flex items-center rounded-[calc(var(--radius-md)-1px)] bg-accent ps-2 font-medium text-accent-foreground text-sm outline-none sm:text-xs/(--text-xs--line-height) [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5"
381 data-slot="combobox-chip"
382 {...props}
383 >
384 <span id={labelId}>{children}</span>
385 <ComboboxChipRemove labelId={labelId} />
386 </ComboboxPrimitive.Chip>
387 );
388}
389
390function ComboboxChipRemove({
391 labelId,
392 ...props
393}: ComboboxPrimitive.ChipRemove.Props & { labelId: string }) {
394 const removeLabelId = `${labelId}-remove`;
395
396 return (
397 <ComboboxPrimitive.ChipRemove
398 aria-labelledby={`${removeLabelId} ${labelId}`}
399 className="h-full shrink-0 cursor-pointer px-1.5 opacity-80 hover:opacity-100 [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5"
400 data-slot="combobox-chip-remove"
401 {...props}
402 >
403 <span id={removeLabelId} className="sr-only">
404 Remove
405 </span>
406 <XIcon aria-hidden="true" />
407 </ComboboxPrimitive.ChipRemove>
408 );
409}
410
411274const useComboboxFilter = ComboboxPrimitive.useFilter;
412275
413276export {
414277 Combobox,
415 ComboboxChipsInput,
416278 ComboboxInput,
417279 ComboboxSearchInput,
418280 ComboboxTrigger,
419281 ComboboxPopup,
420282 ComboboxItem,
421 ComboboxSeparator,
422 ComboboxGroup,
423 ComboboxGroupLabel,
424283 ComboboxEmpty,
425 ComboboxValue,
426284 ComboboxList,
427285 ComboboxListVirtualized,
428286 ComboboxClear,
429287 ComboboxStatus,
430 ComboboxRow,
431 ComboboxCollection,
432 ComboboxChips,
433 ComboboxChip,
434288 useComboboxFilter,
435289};
apps/web/src/components/ui/command.tsx-30
......@@ -7,13 +7,11 @@ import { cn } from "~/lib/utils";
77import {
88 Autocomplete,
99 AutocompleteCollection,
10 AutocompleteEmpty,
1110 AutocompleteGroup,
1211 AutocompleteGroupLabel,
1312 AutocompleteInput,
1413 AutocompleteItem,
1514 AutocompleteList,
16 AutocompleteSeparator,
1715} from "~/components/ui/autocomplete";
1816import { DIALOG_BACKDROP_CLASS, DIALOG_POPUP_CLASS } from "~/components/ui/dialog-styles";
1917import { Button } from "~/components/ui/button";
......@@ -22,8 +20,6 @@ const CommandDialog = CommandDialogPrimitive.Root;
2220
2321const CommandDialogPortal = CommandDialogPrimitive.Portal;
2422
25const CommandCreateHandle = CommandDialogPrimitive.createHandle;
26
2723function CommandDialogTrigger(props: CommandDialogPrimitive.Trigger.Props) {
2824 return <CommandDialogPrimitive.Trigger data-slot="command-dialog-trigger" {...props} />;
2925}
......@@ -135,16 +131,6 @@ function CommandList({ className, ...props }: React.ComponentProps<typeof Autoco
135131 );
136132}
137133
138function CommandEmpty({ className, ...props }: React.ComponentProps<typeof AutocompleteEmpty>) {
139 return (
140 <AutocompleteEmpty
141 className={cn("not-empty:py-6", className)}
142 data-slot="command-empty"
143 {...props}
144 />
145 );
146}
147
148134function CommandPanel({ className, ...props }: React.ComponentProps<"div">) {
149135 return (
150136 <div
......@@ -187,19 +173,6 @@ function CommandItem({ className, ...props }: React.ComponentProps<typeof Autoco
187173 );
188174}
189175
190function CommandSeparator({
191 className,
192 ...props
193}: React.ComponentProps<typeof AutocompleteSeparator>) {
194 return (
195 <AutocompleteSeparator
196 className={cn("my-2", className)}
197 data-slot="command-separator"
198 {...props}
199 />
200 );
201}
202
203176function CommandShortcut({ className, ...props }: React.ComponentProps<"kbd">) {
204177 return (
205178 <kbd
......@@ -241,13 +214,11 @@ function CommandFooterAction({
241214}
242215
243216export {
244 CommandCreateHandle,
245217 Command,
246218 CommandCollection,
247219 CommandDialog,
248220 CommandDialogPopup,
249221 CommandDialogTrigger,
250 CommandEmpty,
251222 CommandFooter,
252223 CommandFooterAction,
253224 CommandGroup,
......@@ -256,6 +227,5 @@ export {
256227 CommandItem,
257228 CommandList,
258229 CommandPanel,
259 CommandSeparator,
260230 CommandShortcut,
261231};
apps/web/src/components/ui/dialog.tsx-3
......@@ -13,8 +13,6 @@ import {
1313} from "~/components/ui/dialog-styles";
1414import { ScrollArea } from "~/components/ui/scroll-area";
1515
16const DialogCreateHandle = DialogPrimitive.createHandle;
17
1816const Dialog = DialogPrimitive.Root;
1917
2018const DialogPortal = DialogPrimitive.Portal;
......@@ -184,7 +182,6 @@ function DialogPanel({
184182}
185183
186184export {
187 DialogCreateHandle,
188185 Dialog,
189186 DialogTrigger,
190187 DialogPortal,
apps/web/src/components/ui/group.tsx-20
......@@ -1,7 +1,4 @@
11"use client";
2
3import { mergeProps } from "@base-ui/react/merge-props";
4import { useRender } from "@base-ui/react/use-render";
52import { cva, type VariantProps } from "class-variance-authority";
63import type * as React from "react";
74
......@@ -48,21 +45,6 @@ function Group({
4845 );
4946}
5047
51function GroupText({ className, render, ...props }: useRender.ComponentProps<"div">) {
52 const defaultProps = {
53 className: cn(
54 "relative inline-flex items-center whitespace-nowrap gap-2 rounded-lg border border-input bg-muted not-dark:bg-clip-padding px-[calc(--spacing(3)-1px)] text-muted-foreground text-base sm:text-sm shadow-xs/5 outline-none transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/6%)] dark:bg-input/64 dark:before:shadow-[0_-1px_--theme(--color-white/6%)] [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 [&_svg]:-mx-0.5",
55 className,
56 ),
57 "data-slot": "group-text",
58 };
59 return useRender({
60 defaultTagName: "div",
61 props: mergeProps(defaultProps, props),
62 render,
63 });
64}
65
6648function GroupSeparator({
6749 className,
6850 orientation = "vertical",
......@@ -85,8 +67,6 @@ function GroupSeparator({
8567export {
8668 Group,
8769 Group as ButtonGroup,
88 GroupText,
89 GroupText as ButtonGroupText,
9070 GroupSeparator,
9171 GroupSeparator as ButtonGroupSeparator,
9272 groupVariants,
apps/web/src/components/ui/input-group.tsx+1-18
......@@ -5,7 +5,6 @@ import type * as React from "react";
55
66import { cn } from "~/lib/utils";
77import { Input, type InputProps } from "~/components/ui/input";
8import { Textarea, type TextareaProps } from "~/components/ui/textarea";
98
109const inputGroupVariants = cva(
1110 "relative inline-flex w-full min-w-0 items-center rounded-[var(--control-radius)] border text-base text-foreground ring-ring/24 transition-shadow has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/64 has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/16 has-[textarea]:h-auto has-data-[align=block-end]:h-auto has-data-[align=block-start]:h-auto has-data-[align=block-end]:flex-col has-data-[align=block-start]:flex-col has-[input:focus-visible,textarea:focus-visible]:border-ring has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/36 has-autofill:bg-foreground/4 has-[input:disabled,textarea:disabled]:opacity-64 has-[input:disabled,textarea:disabled,input:focus-visible,textarea:focus-visible,input[aria-invalid],textarea[aria-invalid]]:shadow-none has-[input:focus-visible,textarea:focus-visible]:ring-[3px] sm:text-sm dark:has-autofill:bg-foreground/8 dark:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/24 has-data-[align=inline-start]:**:[[data-size=sm]_input]:ps-1.5 has-data-[align=inline-end]:**:[[data-size=sm]_input]:pe-1.5 *:[[data-slot=input-control],[data-slot=textarea-control]]:contents *:[[data-slot=input-control],[data-slot=textarea-control]]:before:hidden has-[[data-align=block-start],[data-align=block-end]]:**:[input]:h-auto has-data-[align=inline-start]:**:[input]:ps-2 has-data-[align=inline-end]:**:[input]:pe-2 has-data-[align=block-end]:**:[input]:pt-1.5 has-data-[align=block-start]:**:[input]:pb-1.5 **:[textarea]:min-h-20.5 **:[textarea]:resize-none **:[textarea]:py-[calc(--spacing(3)-1px)] **:[textarea]:max-sm:min-h-23.5 **:[textarea_button]:rounded-[calc(var(--control-radius)-1px)]",
......@@ -90,24 +89,8 @@ function InputGroupAddon({
9089 );
9190}
9291
93function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
94 return (
95 <span
96 className={cn(
97 "[&_svg]:-mx-0.5 line-clamp-1 flex items-center gap-2 text-muted-foreground leading-none in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4.5 sm:in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
98 className,
99 )}
100 {...props}
101 />
102 );
103}
104
10592function InputGroupInput({ className, ...props }: InputProps) {
10693 return <Input className={className} unstyled {...props} />;
10794}
10895
109function InputGroupTextarea({ className, ...props }: TextareaProps) {
110 return <Textarea className={className} unstyled {...props} />;
111}
112
113export { InputGroup, InputGroupAddon, InputGroupText, InputGroupInput, InputGroupTextarea };
96export { InputGroup, InputGroupAddon, InputGroupInput };
apps/web/src/components/ui/menu.tsx-8
......@@ -7,12 +7,8 @@ import type * as React from "react";
77import { cn } from "~/lib/utils";
88import { buttonVariants } from "./button";
99
10const MenuCreateHandle = MenuPrimitive.createHandle;
11
1210const Menu = MenuPrimitive.Root;
1311
14const MenuPortal = MenuPrimitive.Portal;
15
1612function MenuTrigger({ className, children, ...props }: MenuPrimitive.Trigger.Props) {
1713 return (
1814 <MenuPrimitive.Trigger className={className} data-slot="menu-trigger" {...props}>
......@@ -304,12 +300,8 @@ function MenuSubPopup({
304300}
305301
306302export {
307 MenuCreateHandle,
308 MenuCreateHandle as DropdownMenuCreateHandle,
309303 Menu,
310304 Menu as DropdownMenu,
311 MenuPortal,
312 MenuPortal as DropdownMenuPortal,
313305 MenuTrigger,
314306 MenuTrigger as DropdownMenuTrigger,
315307 MenuPopup,
apps/web/src/components/ui/number-field.tsx+7-66
......@@ -4,13 +4,6 @@ import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field
44import { MinusIcon, PlusIcon } from "lucide-react";
55import * as React from "react";
66import { cn } from "~/lib/utils";
7import { Label } from "~/components/ui/label";
8
9export const NumberFieldContext: React.Context<{
10 fieldId: string;
11} | null> = React.createContext<{
12 fieldId: string;
13} | null>(null);
147
158export function NumberField({
169 id,
......@@ -22,18 +15,14 @@ export function NumberField({
2215}): React.ReactElement {
2316 const generatedId = React.useId();
2417 const fieldId = id ?? generatedId;
25 const contextValue = React.useMemo(() => ({ fieldId }), [fieldId]);
26
2718 return (
28 <NumberFieldContext value={contextValue}>
29 <NumberFieldPrimitive.Root
30 className={cn("flex w-full flex-col items-start gap-2", className)}
31 data-size={size}
32 data-slot="number-field"
33 id={fieldId}
34 {...props}
35 />
36 </NumberFieldContext>
19 <NumberFieldPrimitive.Root
20 className={cn("flex w-full flex-col items-start gap-2", className)}
21 data-size={size}
22 data-slot="number-field"
23 id={fieldId}
24 {...props}
25 />
3726 );
3827}
3928
......@@ -105,52 +94,4 @@ export function NumberFieldInput({
10594 );
10695}
10796
108export function NumberFieldScrubArea({
109 className,
110 label,
111 ...props
112}: NumberFieldPrimitive.ScrubArea.Props & {
113 label: string;
114}): React.ReactElement {
115 const context = React.use(NumberFieldContext);
116
117 if (!context) {
118 throw new Error(
119 "NumberFieldScrubArea must be used within a NumberField component for accessibility.",
120 );
121 }
122
123 return (
124 <NumberFieldPrimitive.ScrubArea
125 className={cn("flex cursor-ew-resize", className)}
126 data-slot="number-field-scrub-area"
127 {...props}
128 >
129 <Label className="cursor-ew-resize" htmlFor={context.fieldId}>
130 {label}
131 </Label>
132 <NumberFieldPrimitive.ScrubAreaCursor className="drop-shadow-[0_1px_1px_#0008] filter">
133 <CursorGrowIcon />
134 </NumberFieldPrimitive.ScrubAreaCursor>
135 </NumberFieldPrimitive.ScrubArea>
136 );
137}
138
139export function CursorGrowIcon(props: React.ComponentProps<"svg">): React.ReactElement {
140 return (
141 <svg
142 aria-hidden="true"
143 fill="black"
144 height="14"
145 stroke="white"
146 viewBox="0 0 24 14"
147 width="26"
148 xmlns="http://www.w3.org/2000/svg"
149 {...props}
150 >
151 <path d="M19.5 5.5L6.49737 5.51844V2L1 6.9999L6.5 12L6.49737 8.5L19.5 8.5V12L25 6.9999L19.5 2V5.5Z" />
152 </svg>
153 );
154}
155
15697export { NumberFieldPrimitive };
apps/web/src/components/ui/popover.tsx-11
......@@ -91,16 +91,6 @@ function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
9191 );
9292}
9393
94function PopoverDescription({ className, ...props }: PopoverPrimitive.Description.Props) {
95 return (
96 <PopoverPrimitive.Description
97 className={cn("text-muted-foreground text-sm", className)}
98 data-slot="popover-description"
99 {...props}
100 />
101 );
102}
103
10494export {
10595 PopoverCreateHandle,
10696 Popover,
......@@ -108,6 +98,5 @@ export {
10898 PopoverPopup,
10999 PopoverPopup as PopoverContent,
110100 PopoverTitle,
111 PopoverDescription,
112101 PopoverClose,
113102};
apps/web/src/components/ui/select.tsx+1-51
......@@ -1,10 +1,7 @@
11"use client";
2
3import { mergeProps } from "@base-ui/react/merge-props";
42import { Select as SelectPrimitive } from "@base-ui/react/select";
5import { useRender } from "@base-ui/react/use-render";
63import { cva, type VariantProps } from "class-variance-authority";
7import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from "lucide-react";
4import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
85import type * as React from "react";
96
107import { cn } from "~/lib/utils";
......@@ -37,41 +34,6 @@ const selectTriggerVariants = cva(
3734 },
3835);
3936
40const selectTriggerIconClassName = "-me-1 size-4.5 opacity-80 sm:size-4";
41
42interface SelectButtonProps extends useRender.ComponentProps<"button"> {
43 size?: VariantProps<typeof selectTriggerVariants>["size"];
44 variant?: VariantProps<typeof selectTriggerVariants>["variant"];
45}
46
47function SelectButton({ className, size, variant, render, children, ...props }: SelectButtonProps) {
48 const typeValue: React.ButtonHTMLAttributes<HTMLButtonElement>["type"] = render
49 ? undefined
50 : "button";
51
52 const defaultProps = {
53 children: (
54 <>
55 <span className="flex-1 truncate in-data-placeholder:text-placeholder">{children}</span>
56 {variant === "ghost" ? (
57 <ChevronDownIcon className="-me-1 size-3 opacity-50" />
58 ) : (
59 <ChevronsUpDownIcon className={selectTriggerIconClassName} />
60 )}
61 </>
62 ),
63 className: cn(selectTriggerVariants({ size, variant }), "min-w-none", className),
64 "data-slot": "select-button",
65 type: typeValue,
66 };
67
68 return useRender({
69 defaultTagName: "button",
70 props: mergeProps<"button">(defaultProps, props),
71 render,
72 });
73}
74
7537function SelectTrigger({
7638 className,
7739 size = "default",
......@@ -203,16 +165,6 @@ function SelectItem({
203165 );
204166}
205167
206function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) {
207 return (
208 <SelectPrimitive.Separator
209 className={cn("mx-2 my-1 h-px bg-border", className)}
210 data-slot="select-separator"
211 {...props}
212 />
213 );
214}
215
216168function SelectGroup(props: SelectPrimitive.Group.Props) {
217169 return <SelectPrimitive.Group data-slot="select-group" {...props} />;
218170}
......@@ -230,13 +182,11 @@ function SelectGroupLabel(props: SelectPrimitive.GroupLabel.Props) {
230182export {
231183 Select,
232184 SelectTrigger,
233 SelectButton,
234185 selectTriggerVariants,
235186 SelectValue,
236187 SelectPopup,
237188 SelectPopup as SelectContent,
238189 SelectItem,
239 SelectSeparator,
240190 SelectGroup,
241191 SelectGroupLabel,
242192};
apps/web/src/components/ui/sheet.tsx-54
......@@ -5,20 +5,11 @@ import { XIcon } from "lucide-react";
55import type { CSSProperties } from "react";
66import { cn } from "~/lib/utils";
77import { Button } from "~/components/ui/button";
8import { ScrollArea } from "~/components/ui/scroll-area";
98
109const Sheet = SheetPrimitive.Root;
1110
1211const SheetPortal = SheetPrimitive.Portal;
1312
14function SheetTrigger(props: SheetPrimitive.Trigger.Props) {
15 return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
16}
17
18function SheetClose(props: SheetPrimitive.Close.Props) {
19 return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
20}
21
2213function SheetBackdrop({ className, ...props }: SheetPrimitive.Backdrop.Props) {
2314 return (
2415 <SheetPrimitive.Backdrop
......@@ -146,28 +137,6 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
146137 );
147138}
148139
149function SheetFooter({
150 className,
151 variant = "default",
152 ...props
153}: React.ComponentProps<"div"> & {
154 variant?: "default" | "bare";
155}) {
156 return (
157 <div
158 className={cn(
159 "flex flex-col-reverse gap-2 px-6 sm:flex-row sm:justify-end",
160 variant === "default" && "border-t bg-muted/72 py-4",
161 variant === "bare" &&
162 "in-[[data-slot=sheet-popup]:has([data-slot=sheet-panel])]:pt-3 pt-4 pb-6",
163 className,
164 )}
165 data-slot="sheet-footer"
166 {...props}
167 />
168 );
169}
170
171140function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
172141 return (
173142 <SheetPrimitive.Title
......@@ -188,37 +157,14 @@ function SheetDescription({ className, ...props }: SheetPrimitive.Description.Pr
188157 );
189158}
190159
191function SheetPanel({
192 className,
193 scrollFade = true,
194 ...props
195}: React.ComponentProps<"div"> & { scrollFade?: boolean }) {
196 return (
197 <ScrollArea scrollFade={scrollFade}>
198 <div
199 className={cn(
200 "p-6 in-[[data-slot=sheet-popup]:has([data-slot=sheet-header])]:pt-1 in-[[data-slot=sheet-popup]:has([data-slot=sheet-footer]:not(.border-t))]:pb-1",
201 className,
202 )}
203 data-slot="sheet-panel"
204 {...props}
205 />
206 </ScrollArea>
207 );
208}
209
210160export {
211161 Sheet,
212 SheetTrigger,
213162 SheetPortal,
214 SheetClose,
215163 SheetBackdrop,
216164 SheetBackdrop as SheetOverlay,
217165 SheetPopup,
218166 SheetPopup as SheetContent,
219167 SheetHeader,
220 SheetFooter,
221168 SheetTitle,
222169 SheetDescription,
223 SheetPanel,
224170};
apps/web/src/components/ui/sidebar.test.tsx+1-85
......@@ -1,23 +1,8 @@
1import { renderToStaticMarkup } from "react-dom/server";
21import { describe, expect, it } from "vite-plus/test";
32
4import {
5 SidebarMenuButton,
6 SidebarMenuSubButton,
7 SidebarProvider,
8 SidebarTrigger,
9} from "./sidebar";
103import { resolveSidebarState } from "./sidebarState";
114
12function renderSidebarButton(className?: string) {
13 return renderToStaticMarkup(
14 <SidebarProvider>
15 <SidebarMenuButton className={className}>Projects</SidebarMenuButton>
16 </SidebarProvider>,
17 );
18}
19
20describe("sidebar interactive cursors", () => {
5describe("responsive sidebar state", () => {
216 it("uses mobile sheet visibility for the shared responsive state", () => {
227 expect(resolveSidebarState({ isMobile: true, open: true, openMobile: false })).toBe(
238 "collapsed",
......@@ -27,73 +12,4 @@ describe("sidebar interactive cursors", () => {
2712 "expanded",
2813 );
2914 });
30
31 it("exposes collapsed state for shared titlebar inset styling", () => {
32 const html = renderToStaticMarkup(
33 <SidebarProvider defaultOpen={false}>
34 <div />
35 </SidebarProvider>,
36 );
37
38 expect(html).toContain('data-sidebar-state="collapsed"');
39 });
40
41 it("keeps the sidebar trigger interactive inside Electron drag regions", () => {
42 const html = renderToStaticMarkup(
43 <SidebarProvider>
44 <SidebarTrigger />
45 </SidebarProvider>,
46 );
47
48 expect(html).toContain("[-webkit-app-region:no-drag]");
49 expect(html).toContain("size-[var(--workspace-titlebar-control-size)]!");
50 });
51
52 it("uses shared geometry and icon constraints for menu buttons by default", () => {
53 const html = renderSidebarButton();
54
55 expect(html).toContain('data-slot="sidebar-menu-button"');
56 expect(html).toContain("h-8");
57 expect(html).toContain("rounded-[var(--control-radius)]");
58 expect(html).toContain("px-[var(--sidebar-row-content-inset)]");
59 expect(html).toContain("py-1.5");
60 expect(html).toContain("]:size-4");
61 expect(html).toContain("]:shrink-0");
62 expect(html).toContain("cursor-pointer");
63 expect(html).toContain("gap-[var(--sidebar-control-gap)]");
64 expect(html).toContain("text-[var(--sidebar-icon-color)]");
65 expect(html).not.toContain("[&amp;&gt;svg]:opacity-60");
66 });
67
68 it("applies the shared default treatment to icon-only menu buttons", () => {
69 const html = renderToStaticMarkup(
70 <SidebarProvider>
71 <SidebarMenuButton size="icon">
72 <span>+</span>
73 </SidebarMenuButton>
74 </SidebarProvider>,
75 );
76
77 expect(html).toContain("size-8");
78 expect(html).toContain("justify-center");
79 expect(html).toContain("p-0");
80 expect(html).toContain("font-medium");
81 expect(html).toContain("text-sidebar-muted-foreground/80");
82 });
83
84 it("lets project drag handles override the default pointer cursor", () => {
85 const html = renderSidebarButton("cursor-grab");
86
87 expect(html).toContain("cursor-grab");
88 expect(html).not.toContain("cursor-pointer");
89 });
90
91 it("uses a pointer cursor for submenu buttons", () => {
92 const html = renderToStaticMarkup(
93 <SidebarMenuSubButton render={<button type="button" />}>Show more</SidebarMenuSubButton>,
94 );
95
96 expect(html).toContain('data-slot="sidebar-menu-sub-button"');
97 expect(html).toContain("cursor-pointer");
98 });
9915});
apps/web/src/components/ui/sidebar.tsx-133
......@@ -5,9 +5,7 @@ import { PanelLeftCloseIcon, PanelLeftIcon } from "lucide-react";
55import * as React from "react";
66import { cn } from "~/lib/utils";
77import { Button } from "~/components/ui/button";
8import { Input } from "~/components/ui/input";
98import { ScrollArea } from "~/components/ui/scroll-area";
10import { Separator } from "~/components/ui/separator";
119import {
1210 Sheet,
1311 SheetDescription,
......@@ -15,7 +13,6 @@ import {
1513 SheetPopup,
1614 SheetTitle,
1715} from "~/components/ui/sheet";
18import { Skeleton } from "~/components/ui/skeleton";
1916import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
2017import { useResizeDrag } from "~/hooks/useResizeDrag";
2118import { useIsMobile } from "~/hooks/useMediaQuery";
......@@ -544,17 +541,6 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
544541 );
545542}
546543
547function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
548 return (
549 <Input
550 className={cn("h-8 w-full bg-background shadow-none", className)}
551 data-sidebar="input"
552 data-slot="sidebar-input"
553 {...props}
554 />
555 );
556}
557
558544function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
559545 return (
560546 <div
......@@ -577,17 +563,6 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
577563 );
578564}
579565
580function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
581 return (
582 <Separator
583 className={cn("mx-2 w-auto! bg-sidebar-border", className)}
584 data-sidebar="separator"
585 data-slot="sidebar-separator"
586 {...props}
587 />
588 );
589}
590
591566function SidebarContent({
592567 className,
593568 fixedHeader,
......@@ -635,55 +610,6 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
635610 );
636611}
637612
638function SidebarGroupLabel({ className, render, ...props }: useRender.ComponentProps<"div">) {
639 const defaultProps = {
640 className: cn(
641 "flex h-8 shrink-0 items-center rounded-lg px-2 font-medium text-sidebar-foreground text-xs outline-hidden ring-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
642 "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
643 className,
644 ),
645 "data-sidebar": "group-label",
646 "data-slot": "sidebar-group-label",
647 };
648
649 return useRender({
650 defaultTagName: "div",
651 props: mergeProps(defaultProps, props),
652 render,
653 });
654}
655
656function SidebarGroupAction({ className, render, ...props }: useRender.ComponentProps<"button">) {
657 const defaultProps = {
658 className: cn(
659 "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-ring transition-transform hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0",
660 // Increases the hit area of the button on mobile.
661 "after:-inset-2 after:absolute md:after:hidden",
662 "group-data-[collapsible=icon]:hidden",
663 className,
664 ),
665 "data-sidebar": "group-action",
666 "data-slot": "sidebar-group-action",
667 };
668
669 return useRender({
670 defaultTagName: "button",
671 props: mergeProps(defaultProps, props),
672 render,
673 });
674}
675
676function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) {
677 return (
678 <div
679 className={cn("w-full text-sm", className)}
680 data-sidebar="group-content"
681 data-slot="sidebar-group-content"
682 {...props}
683 />
684 );
685}
686
687613function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
688614 return (
689615 <ul
......@@ -782,58 +708,6 @@ function SidebarMenuButton({
782708 );
783709}
784710
785function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) {
786 return (
787 <div
788 className={cn(
789 "pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-lg px-1 font-medium text-sidebar-foreground text-xs tabular-nums",
790 "peer-hover/menu-button:text-sidebar-foreground peer-data-[active=true]/menu-button:text-sidebar-foreground",
791 "peer-data-[size=sm]/menu-button:top-1",
792 "peer-data-[size=default]/menu-button:top-1.5",
793 "peer-data-[size=lg]/menu-button:top-2.5",
794 "group-data-[collapsible=icon]:hidden",
795 className,
796 )}
797 data-sidebar="menu-badge"
798 data-slot="sidebar-menu-badge"
799 {...props}
800 />
801 );
802}
803
804function SidebarMenuSkeleton({
805 className,
806 showIcon = false,
807 ...props
808}: React.ComponentProps<"div"> & {
809 showIcon?: boolean;
810}) {
811 // Random width between 50 to 90%. Intentionally wrapped in useMemo to avoid changing width on every render.
812 const width = React.useMemo(() => {
813 return `${Math.floor(Math.random() * 40) + 50}%`;
814 }, []);
815
816 return (
817 <div
818 className={cn("flex h-8 items-center gap-2 rounded-lg px-2", className)}
819 data-sidebar="menu-skeleton"
820 data-slot="sidebar-menu-skeleton"
821 {...props}
822 >
823 {showIcon && <Skeleton className="size-4 rounded-lg" data-sidebar="menu-skeleton-icon" />}
824 <Skeleton
825 className="h-4 max-w-(--skeleton-width) flex-1"
826 data-sidebar="menu-skeleton-text"
827 style={
828 {
829 "--skeleton-width": width,
830 } as React.CSSProperties
831 }
832 />
833 </div>
834 );
835}
836
837711function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
838712 return (
839713 <ul
......@@ -897,23 +771,16 @@ export {
897771 SidebarContent,
898772 SidebarFooter,
899773 SidebarGroup,
900 SidebarGroupAction,
901 SidebarGroupContent,
902 SidebarGroupLabel,
903774 SidebarHeader,
904 SidebarInput,
905775 SidebarInset,
906776 SidebarMenu,
907 SidebarMenuBadge,
908777 SidebarMenuButton,
909778 SidebarMenuItem,
910 SidebarMenuSkeleton,
911779 SidebarMenuSub,
912780 SidebarMenuSubButton,
913781 SidebarMenuSubItem,
914782 SidebarProvider,
915783 SidebarRail,
916 SidebarSeparator,
917784 SidebarTrigger,
918785 useSidebar,
919786 useSidebarVisibility,
apps/web/src/components/ui/table.tsx+1-21
......@@ -28,16 +28,6 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
2828 );
2929}
3030
31function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
32 return (
33 <tfoot
34 data-slot="table-footer"
35 className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
36 {...props}
37 />
38 );
39}
40
4131function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
4232 return (
4333 <tr
......@@ -74,14 +64,4 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
7464 );
7565}
7666
77function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
78 return (
79 <caption
80 data-slot="table-caption"
81 className={cn("mt-4 text-xs text-muted-foreground", className)}
82 {...props}
83 />
84 );
85}
86
87export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
67export { Table, TableHeader, TableBody, TableHead, TableRow, TableCell };
apps/web/src/components/ui/toggle-group.tsx+1-21
......@@ -6,7 +6,6 @@ import type { VariantProps } from "class-variance-authority";
66import * as React from "react";
77
88import { cn } from "~/lib/utils";
9import { Separator } from "~/components/ui/separator";
109import { Toggle as ToggleComponent, type toggleVariants } from "~/components/ui/toggle";
1110
1211const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
......@@ -76,23 +75,4 @@ function Toggle({
7675 );
7776}
7877
79function ToggleGroupSeparator({
80 className,
81 orientation = "vertical",
82 ...props
83}: {
84 className?: string;
85} & React.ComponentProps<typeof Separator>) {
86 return (
87 <Separator
88 className={cn(
89 "pointer-events-none relative bg-input before:absolute before:inset-0 dark:before:bg-input/32",
90 className,
91 )}
92 orientation={orientation}
93 {...props}
94 />
95 );
96}
97
98export { ToggleGroup, Toggle, Toggle as ToggleGroupItem, ToggleGroupSeparator };
78export { ToggleGroup, Toggle, Toggle as ToggleGroupItem };
apps/web/src/components/ui/tooltip.tsx+1-3
......@@ -2,8 +2,6 @@ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
22
33import { cn } from "~/lib/utils";
44
5const TooltipCreateHandle = TooltipPrimitive.createHandle;
6
75const TooltipProvider = TooltipPrimitive.Provider;
86
97const Tooltip = TooltipPrimitive.Root;
......@@ -61,4 +59,4 @@ function TooltipPopup({
6159 );
6260}
6361
64export { TooltipCreateHandle, TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup };
62export { TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup };
apps/web/src/providerInstances.test.ts-39
......@@ -9,7 +9,6 @@ import {
99 isProviderInstancePickerVisible,
1010 resolveDefaultProviderModelSelection,
1111 resolveSelectableProviderInstance,
12 resolveProviderDriverKindForInstanceSelection,
1312} from "./providerInstances";
1413
1514function provider(input: {
......@@ -362,44 +361,6 @@ describe("resolveSelectableProviderInstance", () => {
362361 });
363362});
364363
365describe("resolveProviderDriverKindForInstanceSelection", () => {
366 it("maps custom provider instance ids back to their driver kind", () => {
367 const providers = [
368 provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }),
369 provider({
370 provider: ProviderDriverKind.make("claudeAgent"),
371 instanceId: "claude_openrouter",
372 displayName: "Claude OpenRouter",
373 }),
374 ];
375 const entries = deriveProviderInstanceEntries(providers);
376
377 expect(
378 resolveProviderDriverKindForInstanceSelection(
379 entries,
380 providers,
381 ProviderInstanceId.make("claude_openrouter"),
382 ),
383 ).toBe("claudeAgent");
384 });
385
386 it("does not guess a provider kind when the instance selection is unknown", () => {
387 const providers = [
388 provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex", enabled: false }),
389 provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: "claudeAgent" }),
390 ];
391 const entries = deriveProviderInstanceEntries(providers);
392
393 expect(
394 resolveProviderDriverKindForInstanceSelection(
395 entries,
396 providers,
397 ProviderInstanceId.make("removed_instance"),
398 ),
399 ).toBeUndefined();
400 });
401});
402
403364describe("getDefaultProviderInstanceModel", () => {
404365 it("uses the instance's own models, not the default instance of the kind", () => {
405366 const providers = [
apps/web/src/providerInstances.ts-18
......@@ -298,21 +298,3 @@ export function resolveDefaultProviderModelSelection(
298298 const model = getDefaultProviderInstanceModel(providers, instanceId);
299299 return model ? { instanceId, model } : null;
300300}
301
302/**
303 * Resolve an open model-selection routing key back to a driver kind.
304 * Custom instance ids such as `claude_openrouter` are not themselves
305 * driver-kind slugs, but the composer still needs the owning driver kind
306 * for capabilities, options, icons, and turn dispatch metadata.
307 */
308export function resolveProviderDriverKindForInstanceSelection(
309 entries: ReadonlyArray<ProviderInstanceEntry>,
310 providers: ReadonlyArray<ServerProvider>,
311 selection: ProviderInstanceId | ProviderDriverKind | null | undefined,
312): ProviderDriverKind | undefined {
313 const matchedEntry = entries.find((entry) => entry.instanceId === selection);
314 if (matchedEntry) {
315 return matchedEntry.driverKind;
316 }
317 return undefined;
318}
apps/web/src/types.ts-5
......@@ -5,7 +5,6 @@ import type {
55 ChatUnknownAttachment as ContractChatUnknownAttachment,
66 OrchestrationCheckpointFile,
77 OrchestrationCheckpointSummary,
8 OrchestrationLatestTurn,
98 OrchestrationMessage,
109 OrchestrationProposedPlan,
1110 OrchestrationSession,
......@@ -86,9 +85,5 @@ export type Project = EnvironmentProject;
8685export type Thread = EnvironmentThread;
8786export type ThreadShell = EnvironmentThreadShell;
8887
89export interface ThreadTurnState {
90 latestTurn: OrchestrationLatestTurn | null;
91}
92
9388export type SidebarThreadSummary = EnvironmentThreadShell;
9489export type ThreadSession = OrchestrationSession;
apps/web/src/vite-plus-browser-matchers.d.ts deleted-11
......@@ -1,11 +0,0 @@
1import type { ExpectPollOptions } from "vite-plus/test";
2import type { Locator } from "vite-plus/test/browser";
3
4declare module "vite-plus/test" {
5 interface ExpectStatic {
6 element: (
7 element: HTMLElement | SVGElement | null | Locator,
8 options?: ExpectPollOptions,
9 ) => any;
10 }
11}
infra/relay/src/agentActivity/AgentActivityPublisher.test.ts-2
......@@ -84,7 +84,6 @@ function makeEnvironmentLinks(
8484): EnvironmentLinks.EnvironmentLinks["Service"] {
8585 return {
8686 upsert: () => Effect.void,
87 listUsersForEnvironment: () => Effect.succeed(["dev:julius"]),
8887 listDeliveryUsersForEnvironment: () =>
8988 Effect.succeed([
9089 {
......@@ -93,7 +92,6 @@ function makeEnvironmentLinks(
9392 liveActivitiesEnabled: true,
9493 },
9594 ]),
96 listPublicKeysForEnvironment: () => Effect.succeed([]),
9795 listForUser: () => Effect.succeed([]),
9896 getForUser: () => Effect.succeed(null),
9997 revokeForUser: () => Effect.succeed(false),
infra/relay/src/agentActivity/ApnsClient.ts+2-7
......@@ -9,7 +9,7 @@ import * as HttpClient from "effect/unstable/http/HttpClient";
99import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
1010import { ApnsEnvironment as ApnsEnvironmentSchema, type ApnsCredentials } from "../Config.ts";
1111import type { ApnsLiveActivityAlert, ApnsNotificationPayload } from "./apnsDeliveryJobs.ts";
12import { ApnsJwtEncodingError, ApnsJwtSigningError } from "./apnsJwt.ts";
12import type { ApnsJwtEncodingError, ApnsJwtSigningError } from "./apnsJwt.ts";
1313import * as ApnsProviderTokens from "./ApnsProviderTokens.ts";
1414
1515export { ApnsJwtEncodingError, ApnsJwtSigningError } from "./apnsJwt.ts";
......@@ -71,12 +71,7 @@ export class ApnsHttpRequestError extends Schema.TaggedError<ApnsHttpRequestErro
7171 }
7272}
7373
74export const ApnsError = Schema.Union([
75 ApnsJwtEncodingError,
76 ApnsJwtSigningError,
77 ApnsHttpRequestError,
78]);
79export type ApnsError = typeof ApnsError.Type;
74export type ApnsError = ApnsJwtEncodingError | ApnsJwtSigningError | ApnsHttpRequestError;
8075
8176const decodeApnsErrorResponseJson = Schema.decodeUnknownOption(
8277 Schema.fromJsonString(
infra/relay/src/agentActivity/FcmDeliveries.test.ts-2
......@@ -162,7 +162,6 @@ function harness() {
162162 }),
163163 Layer.succeed(EnvironmentLinks, {
164164 upsert: () => Effect.void,
165 listUsersForEnvironment: () => Effect.succeed(["user"]),
166165 listDeliveryUsersForEnvironment: (input) =>
167166 Effect.sync(() =>
168167 current.linked && !current.revokedEnvironments.includes(input.environmentId)
......@@ -177,7 +176,6 @@ function harness() {
177176 ]
178177 : [],
179178 ),
180 listPublicKeysForEnvironment: () => Effect.succeed([]),
181179 listForUser: () => Effect.succeed([]),
182180 revokeForUser: () => Effect.succeed(false),
183181 getForUser: (input) =>
infra/relay/src/agentActivity/MobileRegistrations.test.ts-2
......@@ -106,7 +106,6 @@ function makeEnvironmentLinks(
106106): EnvironmentLinks.EnvironmentLinks["Service"] {
107107 return {
108108 upsert: () => Effect.void,
109 listUsersForEnvironment: () => Effect.succeed(["dev:julius"]),
110109 listDeliveryUsersForEnvironment: () =>
111110 Effect.succeed([
112111 {
......@@ -115,7 +114,6 @@ function makeEnvironmentLinks(
115114 liveActivitiesEnabled: true,
116115 },
117116 ]),
118 listPublicKeysForEnvironment: () => Effect.succeed([]),
119117 listForUser: () => Effect.succeed([]),
120118 getForUser: () => Effect.succeed(null),
121119 revokeForUser: () => Effect.succeed(false),
infra/relay/src/environments/EnvironmentConnector.test.ts-2
......@@ -209,9 +209,7 @@ function makeLinks(
209209): EnvironmentLinks.EnvironmentLinks["Service"] {
210210 return {
211211 upsert: () => Effect.void,
212 listUsersForEnvironment: () => Effect.succeed([]),
213212 listDeliveryUsersForEnvironment: () => Effect.succeed([]),
214 listPublicKeysForEnvironment: () => Effect.succeed([environmentKeyPair.publicKey]),
215213 listForUser: () => Effect.succeed([]),
216214 getForUser: () =>
217215 Effect.succeed({
infra/relay/src/environments/EnvironmentLinker.test.ts-2
......@@ -123,9 +123,7 @@ function testLayer(input?: {
123123 }),
124124 Layer.succeed(EnvironmentLinks.EnvironmentLinks, {
125125 upsert: input?.upsert ?? (() => Effect.void),
126 listUsersForEnvironment: () => Effect.succeed([]),
127126 listDeliveryUsersForEnvironment: () => Effect.succeed([]),
128 listPublicKeysForEnvironment: () => Effect.succeed([]),
129127 listForUser: () => Effect.succeed([]),
130128 getForUser: () => Effect.succeed(null),
131129 revokeForUser: () => Effect.succeed(false),
infra/relay/src/environments/EnvironmentLinks.test.ts+10-4
......@@ -66,7 +66,7 @@ describe("EnvironmentLinks", () => {
6666
6767 expect(error).toMatchObject({
6868 _tag: "EnvironmentLinkUserListPersistenceError",
69 operation: "list-delivery-users",
69 message: "Environment link user query 'list-delivery-users' failed for environment 'env-1'",
7070 environmentId: "env-1",
7171 });
7272 expect(error.cause).toBe(cause);
......@@ -78,7 +78,7 @@ describe("EnvironmentLinks", () => {
7878 );
7979 });
8080
81 it.effect("selects users when either notifications or Live Activities are enabled", () => {
81 it.effect("selects notification or Live Activity users by environment key", () => {
8282 const whereConditions: Array<unknown> = [];
8383 const fakeDb = {
8484 select: (selection: unknown) => {
......@@ -99,7 +99,12 @@ describe("EnvironmentLinks", () => {
9999
100100 return Effect.gen(function* () {
101101 const links = yield* EnvironmentLinks.EnvironmentLinks;
102 expect(yield* links.listUsersForEnvironment({ environmentId: "env-1" })).toEqual([]);
102 expect(
103 yield* links.listDeliveryUsersForEnvironment({
104 environmentId: "env-1",
105 environmentPublicKey: "public-key-1",
106 }),
107 ).toEqual([]);
103108 expect(whereConditions).toHaveLength(1);
104109
105110 const query = new PgDialect().sqlToQuery(whereConditions[0] as never);
......@@ -107,8 +112,9 @@ describe("EnvironmentLinks", () => {
107112 expect(query.sql).toContain('"relay_environment_links"."revoked_at" is null');
108113 expect(query.sql).toContain('"relay_environment_links"."notifications_enabled" = $2');
109114 expect(query.sql).toContain('"relay_environment_links"."live_activities_enabled" = $3');
115 expect(query.sql).toContain('"relay_environment_links"."environment_public_key" = $4');
110116 expect(query.sql).toContain(" or ");
111 expect(query.params).toEqual(["env-1", true, true]);
117 expect(query.params).toEqual(["env-1", true, true, "public-key-1"]);
112118 }).pipe(
113119 Effect.provide(
114120 EnvironmentLinks.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))),
infra/relay/src/environments/EnvironmentLinks.ts+6-80
......@@ -41,25 +41,12 @@ export class EnvironmentLinkUpsertPersistenceError extends Schema.TaggedError<En
4141export class EnvironmentLinkUserListPersistenceError extends Schema.TaggedError<EnvironmentLinkUserListPersistenceError>()(
4242 "EnvironmentLinkUserListPersistenceError",
4343 {
44 operation: Schema.Literals(["list-users", "list-delivery-users"]),
4544 environmentId: Schema.String,
4645 cause: Schema.Defect(),
4746 },
4847) {
4948 override get message(): string {
50 return `Environment link user query '${this.operation}' failed for environment '${this.environmentId}'`;
51 }
52}
53
54export class EnvironmentPublicKeyListPersistenceError extends Schema.TaggedError<EnvironmentPublicKeyListPersistenceError>()(
55 "EnvironmentPublicKeyListPersistenceError",
56 {
57 environmentId: Schema.String,
58 cause: Schema.Defect(),
59 },
60) {
61 override get message(): string {
62 return `Failed to list public keys for environment '${this.environmentId}'`;
49 return `Environment link user query 'list-delivery-users' failed for environment '${this.environmentId}'`;
6350 }
6451}
6552
......@@ -110,9 +97,6 @@ export class EnvironmentLinks extends Context.Service<
11097 readonly proof: RelayEnvironmentLinkProofPayload;
11198 readonly endpoint: RelayManagedEndpoint;
11299 }) => Effect.Effect<void, EnvironmentLinkUpsertPersistenceError>;
113 readonly listUsersForEnvironment: (input: {
114 readonly environmentId: string;
115 }) => Effect.Effect<ReadonlyArray<string>, EnvironmentLinkUserListPersistenceError>;
116100 readonly listDeliveryUsersForEnvironment: (input: {
117101 readonly environmentId: string;
118102 readonly environmentPublicKey: string;
......@@ -120,9 +104,6 @@ export class EnvironmentLinks extends Context.Service<
120104 ReadonlyArray<AgentAwarenessDeliveryUserRecord>,
121105 EnvironmentLinkUserListPersistenceError
122106 >;
123 readonly listPublicKeysForEnvironment: (input: {
124 readonly environmentId: string;
125 }) => Effect.Effect<ReadonlyArray<string>, EnvironmentPublicKeyListPersistenceError>;
126107 readonly listForUser: (input: {
127108 readonly userId: string;
128109 }) => Effect.Effect<
......@@ -140,23 +121,17 @@ export class EnvironmentLinks extends Context.Service<
140121 }
141122>()("t3code-relay/environments/EnvironmentLinks") {}
142123
143function agentAwarenessDeliveryUserCondition(environmentId: string) {
124function agentAwarenessDeliveryUserKeyCondition(input: {
125 readonly environmentId: string;
126 readonly environmentPublicKey: string;
127}) {
144128 return and(
145 eq(relayEnvironmentLinks.environmentId, environmentId),
129 eq(relayEnvironmentLinks.environmentId, input.environmentId),
146130 isNull(relayEnvironmentLinks.revokedAt),
147131 or(
148132 eq(relayEnvironmentLinks.notificationsEnabled, true),
149133 eq(relayEnvironmentLinks.liveActivitiesEnabled, true),
150134 ),
151 );
152}
153
154function agentAwarenessDeliveryUserKeyCondition(input: {
155 readonly environmentId: string;
156 readonly environmentPublicKey: string;
157}) {
158 return and(
159 agentAwarenessDeliveryUserCondition(input.environmentId),
160135 eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey),
161136 );
162137}
......@@ -220,27 +195,6 @@ const make = Effect.gen(function* () {
220195 );
221196 }),
222197
223 listUsersForEnvironment: Effect.fn("relay.environment_links.list_users_for_environment")(
224 function* (input) {
225 yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId });
226 return yield* db
227 .select({ userId: relayEnvironmentLinks.userId })
228 .from(relayEnvironmentLinks)
229 .where(agentAwarenessDeliveryUserCondition(input.environmentId))
230 .pipe(
231 Effect.map((rows) => rows.map((row) => row.userId)),
232 Effect.mapError(
233 (cause) =>
234 new EnvironmentLinkUserListPersistenceError({
235 operation: "list-users",
236 environmentId: input.environmentId,
237 cause,
238 }),
239 ),
240 );
241 },
242 ),
243
244198 listDeliveryUsersForEnvironment: Effect.fn(
245199 "relay.environment_links.list_delivery_users_for_environment",
246200 )(function* (input) {
......@@ -264,34 +218,6 @@ const make = Effect.gen(function* () {
264218 Effect.mapError(
265219 (cause) =>
266220 new EnvironmentLinkUserListPersistenceError({
267 operation: "list-delivery-users",
268 environmentId: input.environmentId,
269 cause,
270 }),
271 ),
272 );
273 }),
274
275 listPublicKeysForEnvironment: Effect.fn(
276 "relay.environment_links.list_public_keys_for_environment",
277 )(function* (input) {
278 yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId });
279 return yield* db
280 .select({ environmentPublicKey: relayEnvironmentLinks.environmentPublicKey })
281 .from(relayEnvironmentLinks)
282 .where(
283 and(
284 eq(relayEnvironmentLinks.environmentId, input.environmentId),
285 isNull(relayEnvironmentLinks.revokedAt),
286 ),
287 )
288 .pipe(
289 Effect.map((rows) => [
290 ...new Set(rows.map((row) => row.environmentPublicKey).filter((key) => key.length > 0)),
291 ]),
292 Effect.mapError(
293 (cause) =>
294 new EnvironmentPublicKeyListPersistenceError({
295221 environmentId: input.environmentId,
296222 cause,
297223 }),
infra/relay/src/http/Api.test.ts-2
......@@ -319,9 +319,7 @@ function relayUnlinkTestLayer(input?: {
319319 EnvironmentLinks.EnvironmentLinks,
320320 EnvironmentLinks.EnvironmentLinks.of({
321321 upsert: () => Effect.die("unused upsert"),
322 listUsersForEnvironment: () => Effect.die("unused listUsersForEnvironment"),
323322 listDeliveryUsersForEnvironment: () => Effect.die("unused listDeliveryUsersForEnvironment"),
324 listPublicKeysForEnvironment: () => Effect.die("unused listPublicKeysForEnvironment"),
325323 listForUser: () => Effect.die("unused listForUser"),
326324 getForUser: input?.getForUser ?? (() => Effect.succeed(null)),
327325 revokeForUser: input?.revokeForUser ?? (() => Effect.succeed(false)),
infra/relay/src/http/Api.ts-1
......@@ -1041,7 +1041,6 @@ const RelayCommonPersistenceError = Schema.Union([
10411041 Devices.DeviceListPersistenceError,
10421042 LiveActivities.LiveActivityRegistrationPersistenceError,
10431043 EnvironmentLinks.EnvironmentLinkUserListPersistenceError,
1044 EnvironmentLinks.EnvironmentPublicKeyListPersistenceError,
10451044 EnvironmentLinks.EnvironmentLinkListPersistenceError,
10461045 EnvironmentLinks.EnvironmentLinkLookupPersistenceError,
10471046 EnvironmentLinks.EnvironmentLinkRevokePersistenceError,
packages/client-runtime/src/authorization/remote.test.ts-40
......@@ -10,7 +10,6 @@ import {
1010 appendClientConnectionParams,
1111 bootstrapRemoteBearerSession,
1212 exchangeRemoteDpopAccessToken,
13 fetchRemoteDpopSessionState,
1413 fetchRemoteSessionState,
1514 issueRemoteDpopWebSocketTicket,
1615 issueRemoteWebSocketTicket,
......@@ -380,45 +379,6 @@ describe("remote environment authorization", () => {
380379 }),
381380 );
382381
383 it.effect("loads remote session state with a DPoP-bound access token", () =>
384 Effect.gen(function* () {
385 const fetch = recordedFetch(
386 Response.json({
387 authenticated: true,
388 auth: {
389 policy: "remote-reachable",
390 bootstrapMethods: ["one-time-token"],
391 sessionMethods: ["dpop-access-token"],
392 sessionCookieName: "t3_session",
393 },
394 sessionMethod: "dpop-access-token",
395 scopes: [
396 "orchestration:read",
397 "orchestration:operate",
398 "terminal:operate",
399 "review:write",
400 ],
401 expiresAt: "2026-05-01T12:00:00.000Z",
402 }),
403 );
404
405 yield* fetchRemoteDpopSessionState({
406 httpBaseUrl: "https://remote.example.com/",
407 accessToken: "dpop-access-token",
408 dpopProof: "dpop-proof",
409 }).pipe(provideRemoteHttp(fetch.fetchFn));
410
411 expectFetchCall(fetch.calls, 1, {
412 url: "https://remote.example.com/api/auth/session",
413 method: "GET",
414 headers: {
415 authorization: "DPoP dpop-access-token",
416 dpop: "dpop-proof",
417 },
418 });
419 }),
420 );
421
422382 it.effect("fails hung fetch requests on the configured timeout", () =>
423383 Effect.gen(function* () {
424384 const fetch = hangingFetch();
packages/client-runtime/src/authorization/remote.ts-21
......@@ -158,27 +158,6 @@ export const fetchRemoteSessionState = Effect.fn(
158158 );
159159});
160160
161export const fetchRemoteDpopSessionState = Effect.fn(
162 "clientRuntime.authorization.fetchRemoteDpopSessionState",
163)(function* (input: {
164 readonly httpBaseUrl: string;
165 readonly accessToken: string;
166 readonly dpopProof: string;
167 readonly timeoutMs?: number;
168}) {
169 const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "auth");
170 return yield* executeEnvironmentHttpRequest(
171 environmentEndpointUrl(input.httpBaseUrl, "/api/auth/session"),
172 input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS,
173 client.session({
174 headers: {
175 authorization: `DPoP ${input.accessToken}`,
176 dpop: input.dpopProof,
177 },
178 }),
179 );
180});
181
182161export const issueRemoteWebSocketTicket = Effect.fn(
183162 "clientRuntime.authorization.issueRemoteWebSocketTicket",
184163)(function* (input: {
packages/contracts/src/environment.ts-14
......@@ -189,14 +189,6 @@ export const ExecutionEnvironmentDescriptor = Schema.Struct({
189189});
190190export type ExecutionEnvironmentDescriptor = typeof ExecutionEnvironmentDescriptor.Type;
191191
192export const EnvironmentConnectionState = Schema.Literals([
193 "connecting",
194 "connected",
195 "disconnected",
196 "error",
197]);
198export type EnvironmentConnectionState = typeof EnvironmentConnectionState.Type;
199
200192export const RepositoryIdentityLocator = Schema.Struct({
201193 source: Schema.Literal("git-remote"),
202194 remoteName: TrimmedNonEmptyString,
......@@ -228,9 +220,3 @@ export const ScopedThreadRef = Schema.Struct({
228220 threadId: ThreadId,
229221});
230222export type ScopedThreadRef = typeof ScopedThreadRef.Type;
231
232export const ScopedThreadSessionRef = Schema.Struct({
233 environmentId: EnvironmentId,
234 threadId: ThreadId,
235});
236export type ScopedThreadSessionRef = typeof ScopedThreadSessionRef.Type;
packages/contracts/src/ipc.ts+2-252
......@@ -1,92 +1,15 @@
1import type {
2 VcsCreateRefInput,
3 VcsCreateRefResult,
4 VcsCreateWorktreeInput,
5 VcsCreateWorktreeResult,
6 VcsInitInput,
7 VcsListRefsInput,
8 VcsListRefsResult,
9 VcsPullInput,
10 VcsPullResult,
11 VcsRemoveWorktreeInput,
12 VcsSwitchRefInput,
13 VcsSwitchRefResult,
14 GitPreparePullRequestThreadInput,
15 GitPreparePullRequestThreadResult,
16 GitPullRequestRefInput,
17 GitResolvePullRequestResult,
18 VcsStatusInput,
19 VcsStatusResult,
20} from "./git.ts";
21import type {
22 ReviewDiffFileContentsInput,
23 ReviewDiffFileContentsResult,
24 ReviewDiffPreviewInput,
25 ReviewDiffPreviewResult,
26} from "./review.ts";
27import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts";
28import type { AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts";
29import type {
30 ProjectListEntriesInput,
31 ProjectListEntriesResult,
32 ProjectReadFileInput,
33 ProjectReadFileResult,
34 ProjectSearchEntriesInput,
35 ProjectSearchEntriesResult,
36 ProjectWriteFileInput,
37 ProjectWriteFileResult,
38} from "./project.ts";
39import type {
40 TerminalAttachInput,
41 TerminalAttachStreamEvent,
42 TerminalClearInput,
43 TerminalCloseInput,
44 TerminalMetadataStreamEvent,
45 TerminalOpenInput,
46 TerminalResizeInput,
47 TerminalRestartInput,
48 TerminalSessionSnapshot,
49 TerminalWriteInput,
50} from "./terminal.ts";
511import * as Schema from "effect/Schema";
52import type {
53 DiscoveredLocalServerList,
54 PreviewCloseInput,
55 PreviewEvent,
56 PreviewListInput,
57 PreviewListResult,
58 PreviewNavigateInput,
59 PreviewOpenInput,
60 PreviewRefreshInput,
61 PreviewReportStatusInput,
62 PreviewResizeInput,
63 PreviewSessionSnapshot,
64} from "./preview.ts";
2
653import {
664 PreviewAutomationClickInput,
675 PreviewAutomationEvaluateInput,
68 PreviewAutomationHost,
69 PreviewAutomationHostFocus,
706 PreviewAutomationPressInput,
71 PreviewAutomationResponse,
727 PreviewAutomationScrollInput,
738 PreviewAutomationSnapshot,
749 PreviewAutomationStatus,
75 PreviewAutomationStreamEvent,
7610 PreviewAutomationTypeInput,
7711 PreviewAutomationWaitForInput,
7812} from "./previewAutomation.ts";
79import type {
80 ClientOrchestrationCommand,
81 OrchestrationGetFullThreadDiffInput,
82 OrchestrationGetFullThreadDiffResult,
83 OrchestrationGetTurnDiffInput,
84 OrchestrationGetTurnDiffResult,
85 OrchestrationShellSnapshot,
86 OrchestrationShellStreamItem,
87 OrchestrationSubscribeThreadInput,
88 OrchestrationThreadStreamItem,
89} from "./orchestration.ts";
9013import { SnapShotSource } from "./orchestration.ts";
9114import { EnvironmentId, TrimmedNonEmptyString } from "./baseSchemas.ts";
9215import { BrowserProfileId } from "./browserProfile.ts";
......@@ -100,14 +23,7 @@ import { AdvertisedEndpoint } from "./remoteAccess.ts";
10023import { ExecutionEnvironmentDescriptor } from "./environment.ts";
10124import { type ClientSettings, type QuitConfirmationMode, SnapShotShortcut } from "./settings.ts";
10225import type { EditorId } from "./editor.ts";
103import type {
104 SourceControlCloneRepositoryInput,
105 SourceControlCloneRepositoryResult,
106 SourceControlPublishRepositoryInput,
107 SourceControlPublishRepositoryResult,
108 SourceControlRepositoryInfo,
109 SourceControlRepositoryLookupInput,
110} from "./sourceControl.ts";
26
11127import type {
11228 DesktopAppActivationRequest,
11329 DesktopAppActivationResponse,
......@@ -694,19 +610,6 @@ export interface DesktopPreviewFavicon {
694610 capturedAt: number;
695611}
696612
697export const DesktopPreviewFaviconSchema: Schema.Codec<DesktopPreviewFavicon> = Schema.Struct({
698 dataUrl: Schema.String.check(
699 Schema.isMaxLength(FAVICON_DATA_URL_MAX_LENGTH),
700 Schema.isPattern(/^data:image\/png;base64,[a-z0-9+/]+={0,2}$/i),
701 ),
702 pageUrl: Schema.String.check(Schema.isMaxLength(2_048)),
703 capturedAt: Schema.Number.check(
704 Schema.isFinite(),
705 Schema.isGreaterThanOrEqualTo(0),
706 Schema.isLessThanOrEqualTo(FAVICON_CAPTURED_AT_MAX),
707 ),
708});
709
710613export interface DesktopPreviewTabState {
711614 tabId: string;
712615 webContentsId: number | null;
......@@ -746,27 +649,6 @@ export const DesktopPreviewAutomationStatusSchema = Schema.Struct({
746649});
747650export type DesktopPreviewAutomationStatus = typeof DesktopPreviewAutomationStatusSchema.Type;
748651
749export const DesktopPreviewNavStatusSchema = Schema.Union([
750 Schema.Struct({ kind: Schema.Literal("Idle") }),
751 Schema.Struct({
752 kind: Schema.Literal("Loading"),
753 url: Schema.String,
754 title: Schema.String,
755 }),
756 Schema.Struct({
757 kind: Schema.Literal("Success"),
758 url: Schema.String,
759 title: Schema.String,
760 }),
761 Schema.Struct({
762 kind: Schema.Literal("LoadFailed"),
763 url: Schema.String,
764 title: Schema.String,
765 code: Schema.Number,
766 description: Schema.String,
767 }),
768]);
769
770652export interface DesktopPreviewPointerEvent {
771653 tabId: string;
772654 phase: "move" | "click";
......@@ -1474,135 +1356,3 @@ export interface LocalApi {
14741356 setClientSettings: (settings: ClientSettings) => Promise<void>;
14751357 };
14761358}
1477
1478/**
1479 * APIs bound to a specific backend environment connection.
1480 *
1481 * These operations must always be routed with explicit environment context.
1482 * They represent remote stateful capabilities such as orchestration, terminal,
1483 * project, VCS, and provider operations. In multi-environment mode, each environment gets
1484 * its own instance of this surface, and callers should resolve it by
1485 * `environmentId` rather than reaching through the local desktop bridge.
1486 */
1487export interface EnvironmentApi {
1488 terminal: {
1489 open: (input: typeof TerminalOpenInput.Encoded) => Promise<TerminalSessionSnapshot>;
1490 attach: (
1491 input: typeof TerminalAttachInput.Encoded,
1492 callback: (event: TerminalAttachStreamEvent) => void,
1493 options?: {
1494 onResubscribe?: () => void;
1495 },
1496 ) => () => void;
1497 write: (input: typeof TerminalWriteInput.Encoded) => Promise<void>;
1498 resize: (input: typeof TerminalResizeInput.Encoded) => Promise<void>;
1499 clear: (input: typeof TerminalClearInput.Encoded) => Promise<void>;
1500 restart: (input: typeof TerminalRestartInput.Encoded) => Promise<TerminalSessionSnapshot>;
1501 close: (input: typeof TerminalCloseInput.Encoded) => Promise<void>;
1502 onMetadata: (
1503 callback: (event: TerminalMetadataStreamEvent) => void,
1504 options?: {
1505 onResubscribe?: () => void;
1506 },
1507 ) => () => void;
1508 };
1509 projects: {
1510 listEntries: (input: ProjectListEntriesInput) => Promise<ProjectListEntriesResult>;
1511 readFile: (input: ProjectReadFileInput) => Promise<ProjectReadFileResult>;
1512 searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>;
1513 writeFile: (input: ProjectWriteFileInput) => Promise<ProjectWriteFileResult>;
1514 };
1515 filesystem: {
1516 browse: (input: FilesystemBrowseInput) => Promise<FilesystemBrowseResult>;
1517 };
1518 assets: {
1519 createUrl: (input: AssetCreateUrlInput) => Promise<AssetCreateUrlResult>;
1520 };
1521 sourceControl: {
1522 lookupRepository: (
1523 input: SourceControlRepositoryLookupInput,
1524 ) => Promise<SourceControlRepositoryInfo>;
1525 cloneRepository: (
1526 input: SourceControlCloneRepositoryInput,
1527 ) => Promise<SourceControlCloneRepositoryResult>;
1528 publishRepository: (
1529 input: SourceControlPublishRepositoryInput,
1530 ) => Promise<SourceControlPublishRepositoryResult>;
1531 };
1532 vcs: {
1533 listRefs: (input: VcsListRefsInput) => Promise<VcsListRefsResult>;
1534 createWorktree: (input: VcsCreateWorktreeInput) => Promise<VcsCreateWorktreeResult>;
1535 removeWorktree: (input: VcsRemoveWorktreeInput) => Promise<void>;
1536 createRef: (input: VcsCreateRefInput) => Promise<VcsCreateRefResult>;
1537 switchRef: (input: VcsSwitchRefInput) => Promise<VcsSwitchRefResult>;
1538 init: (input: VcsInitInput) => Promise<void>;
1539 pull: (input: VcsPullInput) => Promise<VcsPullResult>;
1540 refreshStatus: (input: VcsStatusInput) => Promise<VcsStatusResult>;
1541 onStatus: (
1542 input: VcsStatusInput,
1543 callback: (status: VcsStatusResult) => void,
1544 options?: {
1545 onResubscribe?: () => void;
1546 },
1547 ) => () => void;
1548 };
1549 git: {
1550 resolvePullRequest: (input: GitPullRequestRefInput) => Promise<GitResolvePullRequestResult>;
1551 preparePullRequestThread: (
1552 input: GitPreparePullRequestThreadInput,
1553 ) => Promise<GitPreparePullRequestThreadResult>;
1554 };
1555 review: {
1556 getDiffPreview: (input: ReviewDiffPreviewInput) => Promise<ReviewDiffPreviewResult>;
1557 getDiffFileContents: (
1558 input: ReviewDiffFileContentsInput,
1559 ) => Promise<ReviewDiffFileContentsResult>;
1560 };
1561 orchestration: {
1562 dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>;
1563 getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise<OrchestrationGetTurnDiffResult>;
1564 getFullThreadDiff: (
1565 input: OrchestrationGetFullThreadDiffInput,
1566 ) => Promise<OrchestrationGetFullThreadDiffResult>;
1567 getArchivedShellSnapshot: () => Promise<OrchestrationShellSnapshot>;
1568 subscribeShell: (
1569 callback: (event: OrchestrationShellStreamItem) => void,
1570 options?: {
1571 onResubscribe?: () => void;
1572 },
1573 ) => () => void;
1574 subscribeThread: (
1575 input: OrchestrationSubscribeThreadInput,
1576 callback: (event: OrchestrationThreadStreamItem) => void,
1577 options?: {
1578 onResubscribe?: () => void;
1579 },
1580 ) => () => void;
1581 };
1582 preview: {
1583 open: (input: typeof PreviewOpenInput.Encoded) => Promise<PreviewSessionSnapshot>;
1584 navigate: (input: typeof PreviewNavigateInput.Encoded) => Promise<PreviewSessionSnapshot>;
1585 resize: (input: typeof PreviewResizeInput.Encoded) => Promise<PreviewSessionSnapshot>;
1586 refresh: (input: typeof PreviewRefreshInput.Encoded) => Promise<void>;
1587 close: (input: typeof PreviewCloseInput.Encoded) => Promise<void>;
1588 list: (input: typeof PreviewListInput.Encoded) => Promise<PreviewListResult>;
1589 reportStatus: (input: typeof PreviewReportStatusInput.Encoded) => Promise<void>;
1590 automation: {
1591 connect: (
1592 input: PreviewAutomationHost,
1593 callback: (event: PreviewAutomationStreamEvent) => void,
1594 options?: { onResubscribe?: () => void },
1595 ) => () => void;
1596 respond: (response: PreviewAutomationResponse) => Promise<void>;
1597 focusHost: (input: PreviewAutomationHostFocus) => Promise<void>;
1598 };
1599 onEvent: (
1600 callback: (event: PreviewEvent) => void,
1601 options?: { onResubscribe?: () => void },
1602 ) => () => void;
1603 subscribePorts: (
1604 callback: (servers: DiscoveredLocalServerList) => void,
1605 options?: { onResubscribe?: () => void },
1606 ) => () => void;
1607 };
1608}
packages/contracts/src/orchestration.ts-20
......@@ -2208,26 +2208,6 @@ export const ProviderSessionRuntimeStatus = Schema.Literals([
22082208]);
22092209export type ProviderSessionRuntimeStatus = typeof ProviderSessionRuntimeStatus.Type;
22102210
2211const ProjectionThreadTurnStatus = Schema.Literals([
2212 "running",
2213 "completed",
2214 "interrupted",
2215 "error",
2216]);
2217export type ProjectionThreadTurnStatus = typeof ProjectionThreadTurnStatus.Type;
2218
2219const ProjectionCheckpointRow = Schema.Struct({
2220 threadId: ThreadId,
2221 turnId: TurnId,
2222 checkpointTurnCount: NonNegativeInt,
2223 checkpointRef: CheckpointRef,
2224 status: OrchestrationCheckpointStatus,
2225 files: Schema.Array(OrchestrationCheckpointFile),
2226 assistantMessageId: Schema.NullOr(MessageId),
2227 completedAt: IsoDateTime,
2228});
2229export type ProjectionCheckpointRow = typeof ProjectionCheckpointRow.Type;
2230
22312211export const ProjectionPendingApprovalStatus = Schema.Literals(["pending", "resolved"]);
22322212export type ProjectionPendingApprovalStatus = typeof ProjectionPendingApprovalStatus.Type;
22332213
packages/contracts/src/providerRuntime.test.ts+2-10
......@@ -1,19 +1,11 @@
1import { describe, expect, expectTypeOf, it } from "vite-plus/test";
1import { describe, expect, it } from "vite-plus/test";
22import * as Schema from "effect/Schema";
33
4import {
5 classifyTaskAgentKind,
6 ProviderRuntimeEvent,
7 type ProviderRuntimeEventType,
8} from "./providerRuntime.ts";
4import { classifyTaskAgentKind, ProviderRuntimeEvent } from "./providerRuntime.ts";
95
106const decodeRuntimeEvent = Schema.decodeUnknownSync(ProviderRuntimeEvent);
117
128describe("ProviderRuntimeEvent", () => {
13 it("includes every runtime event in the public event type", () => {
14 expectTypeOf<ProviderRuntimeEvent["type"]>().toEqualTypeOf<ProviderRuntimeEventType>();
15 });
16
179 it("requires input and output totals for complete turn usage", () => {
1810 const completeEvent = {
1911 type: "turn.completed",
packages/contracts/src/providerRuntime.ts-53
......@@ -148,59 +148,6 @@ export const CanonicalRequestType = Schema.Literals([
148148]);
149149export type CanonicalRequestType = typeof CanonicalRequestType.Type;
150150
151const ProviderRuntimeEventType = Schema.Literals([
152 "session.started",
153 "session.configured",
154 "session.state.changed",
155 "session.exited",
156 "thread.started",
157 "thread.state.changed",
158 "thread.metadata.updated",
159 "thread.token-usage.updated",
160 "thread.realtime.started",
161 "thread.realtime.item-added",
162 "thread.realtime.audio.delta",
163 "thread.realtime.error",
164 "thread.realtime.closed",
165 "turn.started",
166 "turn.completed",
167 "turn.aborted",
168 "turn.plan.updated",
169 "turn.proposed.delta",
170 "turn.proposed.completed",
171 "turn.diff.updated",
172 "item.started",
173 "item.updated",
174 "item.completed",
175 "content.delta",
176 "request.opened",
177 "request.resolved",
178 "user-input.requested",
179 "user-input.resolved",
180 "task.started",
181 "task.progress",
182 "task.updated",
183 "task.completed",
184 "hook.started",
185 "hook.progress",
186 "hook.completed",
187 "tool.progress",
188 "tool.summary",
189 "tool.denied",
190 "auth.status",
191 "account.updated",
192 "account.rate-limits.updated",
193 "mcp.status.updated",
194 "mcp.oauth.completed",
195 "model.rerouted",
196 "config.warning",
197 "deprecation.notice",
198 "files.persisted",
199 "runtime.warning",
200 "runtime.error",
201]);
202export type ProviderRuntimeEventType = typeof ProviderRuntimeEventType.Type;
203
204151const SessionStartedType = Schema.Literal("session.started");
205152const SessionConfiguredType = Schema.Literal("session.configured");
206153const SessionStateChangedType = Schema.Literal("session.state.changed");
packages/effect-codex-app-server/scripts/generate.ts+4-2
......@@ -413,7 +413,7 @@ function addAsyncQuestionFields(value: Schema.Json): Schema.Json {
413413 return {
414414 ...value,
415415 properties: {
416 ...properties,
416 ...Object.fromEntries(Object.entries(properties).filter(([key]) => key !== "type")),
417417 delivery: { anyOf: [{ type: "string", enum: ["async"] }, { type: "null" }] },
418418 questions: {
419419 anyOf: [
......@@ -433,6 +433,7 @@ function addAsyncQuestionFields(value: Schema.Json): Schema.Json {
433433 { type: "null" },
434434 ],
435435 },
436 type: itemType,
436437 },
437438 };
438439 }
......@@ -760,7 +761,8 @@ const generateFiles = Effect.fn("generateFiles")(function* () {
760761 for (const [name, schema] of Object.entries(aggregateSchemas).toSorted(([left], [right]) =>
761762 left.localeCompare(right),
762763 )) {
763 generator.addSchema(name, addAsyncQuestionFields(schema) as never);
764 aggregateSchemas[name] = addAsyncQuestionFields(schema);
765 generator.addSchema(name, aggregateSchemas[name] as never);
764766 }
765767
766768 const generatedEntries = new Map<string, string>();
packages/shared/src/nodeSqliteClient.test.ts+1-1
......@@ -5,7 +5,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
55
66import * as SqliteClient from "./nodeSqliteClient.ts";
77
8const layer = it.layer(SqliteClient.layerMemory());
8const layer = it.layer(SqliteClient.layer({ filename: ":memory:" }));
99
1010layer("NodeSqliteClient", (it) => {
1111 it.effect("runs prepared queries and returns positional values", () =>
packages/shared/src/nodeSqliteClient.ts+6-42
......@@ -36,11 +36,6 @@ export interface SqliteClientConfig {
3636 readonly transformQueryNames?: ((str: string) => string) | undefined;
3737}
3838
39export interface SqliteMemoryClientConfig extends Omit<
40 SqliteClientConfig,
41 "filename" | "readonly"
42> {}
43
4439export class UnsupportedNodeSqliteVersionError extends Schema.TaggedError<UnsupportedNodeSqliteVersionError>()(
4540 "UnsupportedNodeSqliteVersionError",
4641 {
......@@ -86,9 +81,8 @@ const checkNodeSqliteCompat = () => {
8681 return Effect.void;
8782};
8883
89const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (
84const make = Effect.fn("makeWithDatabase")(function* (
9085 options: SqliteClientConfig,
91 openDatabase: () => NodeSqlite.DatabaseSync,
9286): Effect.fn.Return<Client.SqlClient, SqlError, Scope.Scope | Reactivity.Reactivity> {
9387 yield* checkNodeSqliteCompat();
9488
......@@ -100,7 +94,11 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (
10094 const makeConnection = Effect.gen(function* () {
10195 const scope = yield* Effect.scope;
10296 const db = yield* Effect.try({
103 try: openDatabase,
97 try: () =>
98 new NodeSqlite.DatabaseSync(options.filename, {
99 readOnly: options.readonly ?? false,
100 allowExtension: options.allowExtension ?? false,
101 }),
104102 catch: (cause) =>
105103 new SqlError({
106104 reason: classifySqliteError(cause, {
......@@ -281,39 +279,5 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (
281279 });
282280});
283281
284const make = (
285 options: SqliteClientConfig,
286): Effect.Effect<Client.SqlClient, SqlError, Scope.Scope | Reactivity.Reactivity> =>
287 makeWithDatabase(
288 options,
289 () =>
290 new NodeSqlite.DatabaseSync(options.filename, {
291 readOnly: options.readonly ?? false,
292 allowExtension: options.allowExtension ?? false,
293 }),
294 );
295
296const makeMemory = (
297 config: SqliteMemoryClientConfig = {},
298): Effect.Effect<Client.SqlClient, SqlError, Scope.Scope | Reactivity.Reactivity> =>
299 makeWithDatabase(
300 {
301 ...config,
302 filename: ":memory:",
303 readonly: false,
304 },
305 () => {
306 const database = new NodeSqlite.DatabaseSync(":memory:", {
307 allowExtension: config.allowExtension ?? false,
308 });
309 return database;
310 },
311 );
312
313282export const layer = (config: SqliteClientConfig): Layer.Layer<Client.SqlClient, SqlError> =>
314283 Layer.effect(Client.SqlClient, make(config)).pipe(Layer.provide(Reactivity.layer));
315
316export const layerMemory = (
317 config: SqliteMemoryClientConfig = {},
318): Layer.Layer<Client.SqlClient, SqlError> =>
319 Layer.effect(Client.SqlClient, makeMemory(config)).pipe(Layer.provide(Reactivity.layer));
packages/shared/src/shell.ts-1
......@@ -11,7 +11,6 @@ import * as Path from "effect/Path";
1111
1212import { HostProcessEnvironment, HostProcessPlatform } from "./hostProcess.ts";
1313import * as Context from "effect/Context";
14
1514const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/;
1615const WINDOWS_PATH_DELIMITER = ";";
1716const POSIX_PATH_DELIMITER = ":";
packages/tailscale/src/tailscale.ts+5-7
......@@ -111,13 +111,11 @@ export class TailscaleCommandTimeoutError extends Schema.TaggedError<TailscaleCo
111111 }
112112}
113113
114export const TailscaleCommandError = Schema.Union([
115 TailscaleCommandSpawnError,
116 TailscaleCommandOutputError,
117 TailscaleCommandExitError,
118 TailscaleCommandTimeoutError,
119]);
120export type TailscaleCommandError = typeof TailscaleCommandError.Type;
114export type TailscaleCommandError =
115 | TailscaleCommandSpawnError
116 | TailscaleCommandOutputError
117 | TailscaleCommandExitError
118 | TailscaleCommandTimeoutError;
121119
122120export class TailscaleStatusParseError extends Schema.TaggedError<TailscaleStatusParseError>()(
123121 "TailscaleStatusParseError",
scripts/dev-runner.ts-11
......@@ -190,17 +190,6 @@ export class DevRunnerHostNotProxiableError extends Schema.TaggedError<DevRunner
190190 }
191191}
192192
193export const DevRunnerError = Schema.Union([
194 DevRunnerConfigurationError,
195 DevRunnerHostNotProxiableError,
196 DevRunnerInvalidPortOffsetError,
197 DevRunnerPortExhaustedError,
198 DevRunnerProcessError,
199 DevRunnerProcessExitError,
200]);
201export type DevRunnerError = typeof DevRunnerError.Type;
202export const isDevRunnerError = Schema.is(DevRunnerError);
203
204193const optionalStringConfig = (name: string): Config.Config<string | undefined> =>
205194 Config.String(name).pipe(
206195 Config.option,
scripts/lib/dev-share.ts+4-7
......@@ -110,13 +110,10 @@ export class DevServeFailedError extends Schema.TaggedError<DevServeFailedError>
110110 }
111111}
112112
113export const DevShareError = Schema.Union([
114 TailscaleUnavailableError,
115 TailnetNameMissingError,
116 DevServeFailedError,
117]);
118export type DevShareError = typeof DevShareError.Type;
119export const isDevShareError = Schema.is(DevShareError);
113export type DevShareError =
114 | TailscaleUnavailableError
115 | TailnetNameMissingError
116 | DevServeFailedError;
120117
121118/**
122119 * Removes any mapping for `webPort`, reporting whether the port is now clear.
scripts/mobile-showcase.ts-13
......@@ -26,8 +26,6 @@ import showcaseConfig, {
2626import {
2727 SHOWCASE_ENVIRONMENTS,
2828 SHOWCASE_PROJECTS,
29 SHOWCASE_TERMINAL_ID,
30 SHOWCASE_THREAD_ID,
3129 seedShowcaseEnvironment,
3230} from "./mobile-showcase-environment.ts";
3331
......@@ -668,17 +666,6 @@ function buildShowcasePairingUrl(host: string, port: number, credential: string)
668666 return url.toString();
669667}
670668
671export function showcaseSceneUrl(scene: ShowcaseScene, environmentId: string): string {
672 if (scene === "threads") return `${APP_SCHEME}://`;
673 if (scene === "environments") return `${APP_SCHEME}://settings/environments`;
674 const threadPath = `threads/${encodeURIComponent(environmentId)}/${SHOWCASE_THREAD_ID}`;
675 if (scene === "thread") return `${APP_SCHEME}://${threadPath}`;
676 if (scene === "terminal") {
677 return `${APP_SCHEME}://${threadPath}/terminal?terminalId=${SHOWCASE_TERMINAL_ID}`;
678 }
679 return `${APP_SCHEME}://${threadPath}/review`;
680}
681
682669export function encodeAndroidPairingUrls(pairingUrls: ReadonlyArray<string>): string {
683670 return `json-uri:${encodeURIComponent(JSON.stringify(pairingUrls))}`;
684671}
scripts/notify-discord-release.test.ts+1-7
......@@ -3,11 +3,7 @@ import * as Effect from "effect/Effect";
33import * as Layer from "effect/Layer";
44import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
55
6import {
7 buildDiscordReleaseAnnouncement,
8 isDiscordReleaseAnnouncementError,
9 postDiscordWebhook,
10} from "./notify-discord-release.ts";
6import { buildDiscordReleaseAnnouncement, postDiscordWebhook } from "./notify-discord-release.ts";
117
128const latestAnnouncement = {
139 target: "latest",
......@@ -135,7 +131,6 @@ it.effect("preserves webhook request context and the full client cause chain", (
135131 assert.equal(error.cause, clientError);
136132 assert.equal((error.cause as HttpClientError.HttpClientError).cause, requestCause);
137133 assert.ok(!error.message.includes(requestCause.message));
138 assert.equal(isDiscordReleaseAnnouncementError(error), true);
139134 });
140135});
141136
......@@ -169,6 +164,5 @@ it.effect("preserves a non-success response error with structured status context
169164 }
170165 assert.equal(error.cause.reason._tag, "StatusCodeError");
171166 assert.ok(!error.message.includes(error.cause.message));
172 assert.equal(isDiscordReleaseAnnouncementError(error), true);
173167 });
174168});
scripts/notify-discord-release.ts-7
......@@ -90,13 +90,6 @@ export class DiscordReleaseWebhookResponseError extends Schema.TaggedError<Disco
9090 }
9191}
9292
93export const DiscordReleaseAnnouncementError = Schema.Union([
94 DiscordReleaseWebhookRequestError,
95 DiscordReleaseWebhookResponseError,
96]);
97export type DiscordReleaseAnnouncementError = typeof DiscordReleaseAnnouncementError.Type;
98export const isDiscordReleaseAnnouncementError = Schema.is(DiscordReleaseAnnouncementError);
99
10093const targetLabels = {
10194 prerelease: "Prerelease",
10295 latest: "Latest",
scripts/sync-reference-repos.ts+5-8
......@@ -91,14 +91,11 @@ export class ReferenceRepoGitSubtreeError extends Schema.TaggedError<ReferenceRe
9191 }
9292}
9393
94export const ReferenceRepoSyncError = Schema.Union([
95 ReferenceRepoSelectionError,
96 ReferenceRepoVersionSourceError,
97 ReferenceRepoVersionResolutionError,
98 ReferenceRepoGitSubtreeError,
99]);
100export type ReferenceRepoSyncError = typeof ReferenceRepoSyncError.Type;
101export const isReferenceRepoSyncError = Schema.is(ReferenceRepoSyncError);
94export type ReferenceRepoSyncError =
95 | ReferenceRepoSelectionError
96 | ReferenceRepoVersionSourceError
97 | ReferenceRepoVersionResolutionError
98 | ReferenceRepoGitSubtreeError;
10299
103100const decodeJsonSource = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
104101const decodeYamlSource = Schema.decodeEffect(fromYaml(Schema.Unknown));