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) =>...@@ -57,7 +57,6 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
57 }),57 }),
58 setAppUserModelId: () => Effect.void,58 setAppUserModelId: () => Effect.void,
59 getAppMetrics: Effect.succeed([]),59 getAppMetrics: Effect.succeed([]),
60 isDefaultProtocolClient: () => Effect.succeed(false),
61 setAsDefaultProtocolClient: () => Effect.succeed(true),60 setAsDefaultProtocolClient: () => Effect.succeed(true),
62 setDesktopName: () => Effect.void,61 setDesktopName: () => Effect.void,
63 setDockIcon: (iconPath) =>62 setDockIcon: (iconPath) =>
apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts+27-7
...@@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest";...@@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest";
3import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform";3import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform";
4import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";4import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
5import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
6import * as Encoding from "effect/Encoding";
6import * as FileSystem from "effect/FileSystem";7import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";8import * as Path from "effect/Path";
8import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
...@@ -22,6 +23,11 @@ const textEncoder = new TextEncoder();...@@ -22,6 +23,11 @@ const textEncoder = new TextEncoder();
22const decodeConnectionCatalog = Schema.decodeEffect(23const decodeConnectionCatalog = Schema.decodeEffect(
23 Schema.fromJsonString(ConnectionCatalogDocument),24 Schema.fromJsonString(ConnectionCatalogDocument),
24);25);
26const encodeLegacySavedEnvironments = Schema.encodeEffect(
27 Schema.fromJsonString(
28 Schema.Struct({ version: Schema.Literal(1), records: Schema.Array(Schema.Unknown) }),
29 ),
30);
25function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref<boolean> | null = null) {31function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref<boolean> | null = null) {
26 return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, {32 return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, {
27 isEncryptionAvailable: Effect.succeed(available),33 isEncryptionAvailable: Effect.succeed(available),
...@@ -125,7 +131,8 @@ describe("DesktopConnectionCatalogStore", () => {...@@ -125,7 +131,8 @@ describe("DesktopConnectionCatalogStore", () => {
125 withStore(131 withStore(
126 Effect.gen(function* () {132 Effect.gen(function* () {
127 const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore;133 const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore;
128 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;134 const environment = yield* DesktopEnvironment.DesktopEnvironment;
135 const fileSystem = yield* FileSystem.FileSystem;
129 const records: readonly PersistedSavedEnvironmentRecord[] = [136 const records: readonly PersistedSavedEnvironmentRecord[] = [
130 {137 {
131 environmentId: EnvironmentId.make("relay-environment"),138 environmentId: EnvironmentId.make("relay-environment"),
...@@ -159,11 +166,21 @@ describe("DesktopConnectionCatalogStore", () => {...@@ -159,11 +166,21 @@ describe("DesktopConnectionCatalogStore", () => {
159 lastConnectedAt: null,166 lastConnectedAt: null,
160 },167 },
161 ];168 ];
162 yield* savedEnvironments.setRegistry(records);169 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
163 assert.isTrue(170 yield* fileSystem.writeFileString(
164 yield* savedEnvironments.setSecret({171 environment.savedEnvironmentRegistryPath,
165 environmentId: EnvironmentId.make("bearer-environment"),172 yield* encodeLegacySavedEnvironments({
166 secret: "legacy-token",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 ),
167 }),184 }),
168 );185 );
169186
...@@ -218,7 +235,10 @@ describe("DesktopConnectionCatalogStore", () => {...@@ -218,7 +235,10 @@ describe("DesktopConnectionCatalogStore", () => {
218 assert.equal(catalog.credentials[0].credential.token, "legacy-token");235 assert.equal(catalog.credentials[0].credential.token, "legacy-token");
219 }236 }
220237
221 yield* savedEnvironments.setRegistry([]);238 yield* fileSystem.writeFileString(
239 environment.savedEnvironmentRegistryPath,
240 '{"version":1,"records":[]}',
241 );
222 assert.deepEqual(yield* store.get, migrated);242 assert.deepEqual(yield* store.get, migrated);
223 }),243 }),
224 ),244 ),
apps/desktop/src/app/DesktopLifecycle.test.ts-1
...@@ -43,7 +43,6 @@ function makeElectronAppLayer(...@@ -43,7 +43,6 @@ function makeElectronAppLayer(
43 setAboutPanelOptions: () => Effect.void,43 setAboutPanelOptions: () => Effect.void,
44 setAppUserModelId: () => Effect.void,44 setAppUserModelId: () => Effect.void,
45 getAppMetrics: Effect.succeed([]),45 getAppMetrics: Effect.succeed([]),
46 isDefaultProtocolClient: () => Effect.succeed(false),
47 setAsDefaultProtocolClient: () => Effect.succeed(true),46 setAsDefaultProtocolClient: () => Effect.succeed(true),
48 setDesktopName: () => Effect.void,47 setDesktopName: () => Effect.void,
49 setDockIcon: () => Effect.void,48 setDockIcon: () => Effect.void,
apps/desktop/src/backend/DesktopBackendManager.test.ts+2-4
...@@ -165,9 +165,7 @@ function makeTestInstance(input: MakeInstanceInput) {...@@ -165,9 +165,7 @@ function makeTestInstance(input: MakeInstanceInput) {
165 latest: Effect.succeed(Option.none()),165 latest: Effect.succeed(Option.none()),
166 changes: Stream.empty,166 changes: Stream.empty,
167 encoded: input.desktopTelemetryStream ?? Stream.empty,167 encoded: input.desktopTelemetryStream ?? Stream.empty,
168 handleControl: () => Effect.void,168 handleControlForSource: () => Effect.void,
169 handleControlForSource: (_sourceId, message) =>
170 (input.desktopTelemetryPublisher?.handleControl ?? (() => Effect.void))(message),
171 removeControlSource: () => Effect.void,169 removeControlSource: () => Effect.void,
172 publishUpdateReport: () => Effect.void,170 publishUpdateReport: () => Effect.void,
173 updateRequests: Stream.empty,171 updateRequests: Stream.empty,
...@@ -596,7 +594,7 @@ describe("DesktopBackendManager", () => {...@@ -596,7 +594,7 @@ describe("DesktopBackendManager", () => {
596 const instance = yield* makeTestInstance({594 const instance = yield* makeTestInstance({
597 spawnerLayer,595 spawnerLayer,
598 desktopTelemetryPublisher: {596 desktopTelemetryPublisher: {
599 handleControl: (message) =>597 handleControlForSource: (_sourceId, message) =>
600 message.type === "setDiagnosticsDemand"598 message.type === "setDiagnosticsDemand"
601 ? Deferred.succeed(handled, message.enabled).pipe(Effect.asVoid)599 ? Deferred.succeed(handled, message.enabled).pipe(Effect.asVoid)
602 : Effect.void,600 : Effect.void,
apps/desktop/src/backend/DesktopBackendPool.test.ts-1
...@@ -70,7 +70,6 @@ function makePoolLayer(...@@ -70,7 +70,6 @@ function makePoolLayer(
70 latest: Effect.succeed(Option.none()),70 latest: Effect.succeed(Option.none()),
71 changes: Stream.empty,71 changes: Stream.empty,
72 encoded: Stream.empty,72 encoded: Stream.empty,
73 handleControl: () => Effect.void,
74 handleControlForSource: () => Effect.void,73 handleControlForSource: () => Effect.void,
75 removeControlSource: () => Effect.void,74 removeControlSource: () => Effect.void,
76 publishUpdateReport: () => Effect.void,75 publishUpdateReport: () => Effect.void,
apps/desktop/src/electron/ElectronApp.test.ts-3
...@@ -10,7 +10,6 @@ const {...@@ -10,7 +10,6 @@ const {
10 getAppPathMock,10 getAppPathMock,
11 getSystemLocaleMock,11 getSystemLocaleMock,
12 getVersionMock,12 getVersionMock,
13 isDefaultProtocolClientMock,
14 onMock,13 onMock,
15 quitMock,14 quitMock,
16 relaunchMock,15 relaunchMock,
...@@ -32,7 +31,6 @@ const {...@@ -32,7 +31,6 @@ const {
32 getAppPathMock: vi.fn(() => "/app"),31 getAppPathMock: vi.fn(() => "/app"),
33 getSystemLocaleMock: vi.fn(() => "en-GB"),32 getSystemLocaleMock: vi.fn(() => "en-GB"),
34 getVersionMock: vi.fn(() => "1.2.3"),33 getVersionMock: vi.fn(() => "1.2.3"),
35 isDefaultProtocolClientMock: vi.fn(() => false),
36 onMock: vi.fn(),34 onMock: vi.fn(),
37 quitMock: vi.fn(),35 quitMock: vi.fn(),
38 relaunchMock: vi.fn(),36 relaunchMock: vi.fn(),
...@@ -64,7 +62,6 @@ vi.mock("electron", () => ({...@@ -64,7 +62,6 @@ vi.mock("electron", () => ({
64 getAppPath: getAppPathMock,62 getAppPath: getAppPathMock,
65 getSystemLocale: getSystemLocaleMock,63 getSystemLocale: getSystemLocaleMock,
66 getVersion: getVersionMock,64 getVersion: getVersionMock,
67 isDefaultProtocolClient: isDefaultProtocolClientMock,
68 isPackaged: true,65 isPackaged: true,
69 name: "T3 Code",66 name: "T3 Code",
70 on: onMock,67 on: onMock,
apps/desktop/src/electron/ElectronApp.ts-3
...@@ -64,7 +64,6 @@ export class ElectronApp extends Context.Service<...@@ -64,7 +64,6 @@ export class ElectronApp extends Context.Service<
64 ) => Effect.Effect<void>;64 ) => Effect.Effect<void>;
65 readonly setAppUserModelId: (id: string) => Effect.Effect<void>;65 readonly setAppUserModelId: (id: string) => Effect.Effect<void>;
66 readonly getAppMetrics: Effect.Effect<ReadonlyArray<Electron.ProcessMetric>>;66 readonly getAppMetrics: Effect.Effect<ReadonlyArray<Electron.ProcessMetric>>;
67 readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect<boolean>;
68 readonly setAsDefaultProtocolClient: (67 readonly setAsDefaultProtocolClient: (
69 protocol: string,68 protocol: string,
70 path?: string,69 path?: string,
...@@ -166,8 +165,6 @@ export const make = ElectronApp.of({...@@ -166,8 +165,6 @@ export const make = ElectronApp.of({
166 Electron.app.setAppUserModelId(id);165 Electron.app.setAppUserModelId(id);
167 }),166 }),
168 getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()),167 getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()),
169 isDefaultProtocolClient: (protocol) =>
170 Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)),
171 setAsDefaultProtocolClient: (protocol, path, args) =>168 setAsDefaultProtocolClient: (protocol, path, args) =>
172 Effect.sync(() => {169 Effect.sync(() => {
173 if (path === undefined) {170 if (path === undefined) {
apps/desktop/src/settings/DesktopSavedEnvironments.test.ts+36-170
...@@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";...@@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";2import { assert, describe, it } from "@effect/vitest";
3import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";3import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Encoding from "effect/Encoding";
5import * as FileSystem from "effect/FileSystem";6import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";7import * as Path from "effect/Path";
7import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
...@@ -39,16 +40,29 @@ const SavedEnvironmentRegistryDocumentProbe = Schema.Struct({...@@ -39,16 +40,29 @@ const SavedEnvironmentRegistryDocumentProbe = Schema.Struct({
39const SavedEnvironmentRegistryDocumentProbeJson = Schema.fromJsonString(40const SavedEnvironmentRegistryDocumentProbeJson = Schema.fromJsonString(
40 SavedEnvironmentRegistryDocumentProbe,41 SavedEnvironmentRegistryDocumentProbe,
41);42);
42const decodeSavedEnvironmentRegistryDocumentProbe = Schema.decodeEffect(
43 SavedEnvironmentRegistryDocumentProbeJson,
44);
45const encodeSavedEnvironmentRegistryDocumentProbe = Schema.encodeEffect(43const encodeSavedEnvironmentRegistryDocumentProbe = Schema.encodeEffect(
46 SavedEnvironmentRegistryDocumentProbeJson,44 SavedEnvironmentRegistryDocumentProbeJson,
47);45);
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
48function makeSafeStorageLayer(input: {63function makeSafeStorageLayer(input: {
49 readonly available: boolean;64 readonly available: boolean;
50 readonly availabilityError?: unknown;65 readonly availabilityError?: unknown;
51 readonly encryptError?: unknown;
52 readonly decryptError?: unknown;66 readonly decryptError?: unknown;
53}) {67}) {
54 return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, {68 return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, {
...@@ -60,14 +74,7 @@ function makeSafeStorageLayer(input: {...@@ -60,14 +74,7 @@ function makeSafeStorageLayer(input: {
60 cause: input.availabilityError,74 cause: input.availabilityError,
61 }),75 }),
62 ),76 ),
63 encryptString: (value) =>77 encryptString: (value) => Effect.succeed(textEncoder.encode(`enc:${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 ),
71 decryptString: (value) => {78 decryptString: (value) => {
72 if (input.decryptError !== undefined) {79 if (input.decryptError !== undefined) {
73 return Effect.fail(80 return Effect.fail(
...@@ -96,7 +103,6 @@ function makeLayer(...@@ -96,7 +103,6 @@ function makeLayer(
96 options?: {103 options?: {
97 readonly availableSecretStorage?: boolean;104 readonly availableSecretStorage?: boolean;
98 readonly availabilityError?: unknown;105 readonly availabilityError?: unknown;
99 readonly encryptError?: unknown;
100 readonly decryptError?: unknown;106 readonly decryptError?: unknown;
101 },107 },
102 fileSystemLayer: Layer.Layer<FileSystem.FileSystem> = NodeServices.layer,108 fileSystemLayer: Layer.Layer<FileSystem.FileSystem> = NodeServices.layer,
...@@ -120,7 +126,6 @@ function makeLayer(...@@ -120,7 +126,6 @@ function makeLayer(
120 const safeStorageLayer = makeSafeStorageLayer({126 const safeStorageLayer = makeSafeStorageLayer({
121 available: options?.availableSecretStorage ?? true,127 available: options?.availableSecretStorage ?? true,
122 availabilityError: options?.availabilityError,128 availabilityError: options?.availabilityError,
123 encryptError: options?.encryptError,
124 decryptError: options?.decryptError,129 decryptError: options?.decryptError,
125 });130 });
126 const dependencies = Layer.mergeAll(131 const dependencies = Layer.mergeAll(
...@@ -138,7 +143,6 @@ const withSavedEnvironments = <A, E, R>(...@@ -138,7 +143,6 @@ const withSavedEnvironments = <A, E, R>(
138 options?: {143 options?: {
139 readonly availableSecretStorage?: boolean;144 readonly availableSecretStorage?: boolean;
140 readonly availabilityError?: unknown;145 readonly availabilityError?: unknown;
141 readonly encryptError?: unknown;
142 readonly decryptError?: unknown;146 readonly decryptError?: unknown;
143 },147 },
144) =>148) =>
...@@ -151,20 +155,13 @@ const withSavedEnvironments = <A, E, R>(...@@ -151,20 +155,13 @@ const withSavedEnvironments = <A, E, R>(
151 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);155 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
152156
153describe("DesktopSavedEnvironments", () => {157describe("DesktopSavedEnvironments", () => {
154 it.effect("persists and reloads saved environment metadata", () =>158 it.effect("reads saved environment metadata", () =>
155 withSavedEnvironments(159 withSavedEnvironments(
156 Effect.gen(function* () {160 Effect.gen(function* () {
157 const environment = yield* DesktopEnvironment.DesktopEnvironment;
158 const fileSystem = yield* FileSystem.FileSystem;
159 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;161 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
160 yield* savedEnvironments.setRegistry([savedRegistryRecord]);162 yield* seedSavedEnvironmentRegistry();
161163
162 assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]);164 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);
168 }),165 }),
169 ),166 ),
170 );167 );
...@@ -205,17 +202,12 @@ describe("DesktopSavedEnvironments", () => {...@@ -205,17 +202,12 @@ describe("DesktopSavedEnvironments", () => {
205 ),202 ),
206 );203 );
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", () =>
209 withSavedEnvironments(206 withSavedEnvironments(
210 Effect.gen(function* () {207 Effect.gen(function* () {
211 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;208 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
212 yield* savedEnvironments.setRegistry([savedRegistryRecord]);209 yield* seedSavedEnvironmentRegistry(
213210 Encoding.encodeBase64(textEncoder.encode("enc:bearer-token")),
214 assert.isTrue(
215 yield* savedEnvironments.setSecret({
216 environmentId: savedRegistryRecord.environmentId,
217 secret: "bearer-token",
218 }),
219 );211 );
220212
221 assert.deepEqual(213 assert.deepEqual(
...@@ -230,14 +222,8 @@ describe("DesktopSavedEnvironments", () => {...@@ -230,14 +222,8 @@ describe("DesktopSavedEnvironments", () => {
230 withSavedEnvironments(222 withSavedEnvironments(
231 Effect.gen(function* () {223 Effect.gen(function* () {
232 const environment = yield* DesktopEnvironment.DesktopEnvironment;224 const environment = yield* DesktopEnvironment.DesktopEnvironment;
233 const fileSystem = yield* FileSystem.FileSystem;
234 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;225 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
235 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });226 yield* seedSavedEnvironmentRegistry("%%%");
236 const encoded = yield* encodeSavedEnvironmentRegistryDocumentProbe({
237 version: 1,
238 records: [{ ...savedRegistryRecord, encryptedBearerToken: "%%%" }],
239 });
240 yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, `${encoded}\n`);
241227
242 const error = yield* savedEnvironments228 const error = yield* savedEnvironments
243 .getSecret(savedRegistryRecord.environmentId)229 .getSecret(savedRegistryRecord.environmentId)
...@@ -256,17 +242,17 @@ describe("DesktopSavedEnvironments", () => {...@@ -256,17 +242,17 @@ describe("DesktopSavedEnvironments", () => {
256 ),242 ),
257 );243 );
258244
259 it.effect("returns false when writing secrets while encryption is unavailable", () =>245 it.effect("returns no secret while encryption is unavailable", () =>
260 withSavedEnvironments(246 withSavedEnvironments(
261 Effect.gen(function* () {247 Effect.gen(function* () {
262 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;248 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(253 assert.deepEqual(
266 yield* savedEnvironments.setSecret({254 yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId),
267 environmentId: savedRegistryRecord.environmentId,255 Option.none(),
268 secret: "next-token",
269 }),
270 );256 );
271 }),257 }),
272 { availableSecretStorage: false },258 { availableSecretStorage: false },
...@@ -279,13 +265,12 @@ describe("DesktopSavedEnvironments", () => {...@@ -279,13 +265,12 @@ describe("DesktopSavedEnvironments", () => {
279 Effect.gen(function* () {265 Effect.gen(function* () {
280 const environment = yield* DesktopEnvironment.DesktopEnvironment;266 const environment = yield* DesktopEnvironment.DesktopEnvironment;
281 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;267 const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
282 yield* savedEnvironments.setRegistry([savedRegistryRecord]);268 yield* seedSavedEnvironmentRegistry(
269 Encoding.encodeBase64(textEncoder.encode("enc:bearer-token")),
270 );
283271
284 const error = yield* savedEnvironments272 const error = yield* savedEnvironments
285 .setSecret({273 .getSecret(savedRegistryRecord.environmentId)
286 environmentId: savedRegistryRecord.environmentId,
287 secret: "next-token",
288 })
289 .pipe(Effect.flip);274 .pipe(Effect.flip);
290275
291 assert.instanceOf(276 assert.instanceOf(
...@@ -309,45 +294,6 @@ describe("DesktopSavedEnvironments", () => {...@@ -309,45 +294,6 @@ describe("DesktopSavedEnvironments", () => {
309 );294 );
310 });295 });
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
351 it.effect("treats empty saved environment documents as empty", () =>297 it.effect("treats empty saved environment documents as empty", () =>
352 withSavedEnvironments(298 withSavedEnvironments(
353 Effect.gen(function* () {299 Effect.gen(function* () {
...@@ -388,13 +334,6 @@ describe("DesktopSavedEnvironments", () => {...@@ -388,13 +334,6 @@ describe("DesktopSavedEnvironments", () => {
388 secretError,334 secretError,
389 DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError,335 DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError,
390 );336 );
391 const mutationError = yield* savedEnvironments
392 .setRegistry([savedRegistryRecord])
393 .pipe(Effect.flip);
394 assert.instanceOf(
395 mutationError,
396 DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError,
397 );
398 }),337 }),
399 ),338 ),
400 );339 );
...@@ -431,77 +370,4 @@ describe("DesktopSavedEnvironments", () => {...@@ -431,77 +370,4 @@ describe("DesktopSavedEnvironments", () => {
431 assert.notEqual(error.message, permissionError.message);370 assert.notEqual(error.message, permissionError.message);
432 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),371 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
433 );372 );
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 );
507});373});
apps/desktop/src/settings/DesktopSavedEnvironments.ts+3-270
...@@ -1,13 +1,11 @@...@@ -1,13 +1,11 @@
1import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";1import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
2import { fromLenientJson } from "@t3tools/shared/schemaJson";2import { fromLenientJson } from "@t3tools/shared/schemaJson";
3import * as Context from "effect/Context";3import * as Context from "effect/Context";
4import * as Crypto from "effect/Crypto";
5import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
6import * as Encoding from "effect/Encoding";5import * as Encoding from "effect/Encoding";
7import * as FileSystem from "effect/FileSystem";6import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";8import * as Option from "effect/Option";
10import * as Path from "effect/Path";
11import * as Schema from "effect/Schema";9import * as Schema from "effect/Schema";
1210
13import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";11import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
...@@ -65,37 +63,12 @@ const SavedEnvironmentRegistryDocumentJson = fromLenientJson(...@@ -65,37 +63,12 @@ const SavedEnvironmentRegistryDocumentJson = fromLenientJson(
65const decodeSavedEnvironmentRegistryDocumentJson = Schema.decodeEffect(63const decodeSavedEnvironmentRegistryDocumentJson = Schema.decodeEffect(
66 SavedEnvironmentRegistryDocumentJson,64 SavedEnvironmentRegistryDocumentJson,
67);65);
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
80const DesktopSavedEnvironmentSecretProtectionOperation = Schema.Literals([67const DesktopSavedEnvironmentSecretProtectionOperation = Schema.Literals([
81 "check-encryption-availability",68 "check-encryption-availability",
82 "encrypt-secret",
83 "decrypt-secret",69 "decrypt-secret",
84]);70]);
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
99export class DesktopSavedEnvironmentsReadError extends Schema.TaggedError<DesktopSavedEnvironmentsReadError>()(72export class DesktopSavedEnvironmentsReadError extends Schema.TaggedError<DesktopSavedEnvironmentsReadError>()(
100 "DesktopSavedEnvironmentsReadError",73 "DesktopSavedEnvironmentsReadError",
101 {74 {
...@@ -152,19 +125,12 @@ export type DesktopSavedEnvironmentsReadRegistryError =...@@ -152,19 +125,12 @@ export type DesktopSavedEnvironmentsReadRegistryError =
152 | DesktopSavedEnvironmentsReadError125 | DesktopSavedEnvironmentsReadError
153 | DesktopSavedEnvironmentsDocumentDecodeError;126 | DesktopSavedEnvironmentsDocumentDecodeError;
154127
155export type DesktopSavedEnvironmentsMutationError =
156 | DesktopSavedEnvironmentsReadRegistryError
157 | DesktopSavedEnvironmentsWriteError;
158
159export type DesktopSavedEnvironmentsGetSecretError =128export type DesktopSavedEnvironmentsGetSecretError =
160 | DesktopSavedEnvironmentsReadRegistryError129 | DesktopSavedEnvironmentsReadRegistryError
161 | DesktopSavedEnvironmentSecretDecodeError130 | DesktopSavedEnvironmentSecretDecodeError
162 | DesktopSavedEnvironmentSecretProtectionError;131 | DesktopSavedEnvironmentSecretProtectionError;
163132
164export type DesktopSavedEnvironmentsSetSecretError =133/** Reads the previous registry for connection-catalog migration. */
165 | DesktopSavedEnvironmentsMutationError
166 | DesktopSavedEnvironmentSecretProtectionError;
167
168export class DesktopSavedEnvironments extends Context.Service<134export class DesktopSavedEnvironments extends Context.Service<
169 DesktopSavedEnvironments,135 DesktopSavedEnvironments,
170 {136 {
...@@ -172,22 +138,10 @@ export class DesktopSavedEnvironments extends Context.Service<...@@ -172,22 +138,10 @@ export class DesktopSavedEnvironments extends Context.Service<
172 readonly PersistedSavedEnvironmentRecord[],138 readonly PersistedSavedEnvironmentRecord[],
173 DesktopSavedEnvironmentsReadRegistryError139 DesktopSavedEnvironmentsReadRegistryError
174 >;140 >;
175 readonly setRegistry: (141
176 records: readonly PersistedSavedEnvironmentRecord[],
177 ) => Effect.Effect<void, DesktopSavedEnvironmentsMutationError>;
178 readonly removeEnvironment: (
179 environmentId: string,
180 ) => Effect.Effect<void, DesktopSavedEnvironmentsMutationError>;
181 readonly getSecret: (142 readonly getSecret: (
182 environmentId: string,143 environmentId: string,
183 ) => Effect.Effect<Option.Option<string>, DesktopSavedEnvironmentsGetSecretError>;144 ) => 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>;
191 }145 }
192>()("@t3tools/desktop/settings/DesktopSavedEnvironments") {}146>()("@t3tools/desktop/settings/DesktopSavedEnvironments") {}
193147
...@@ -209,28 +163,6 @@ function toPersistedSavedEnvironmentRecord(...@@ -209,28 +163,6 @@ function toPersistedSavedEnvironmentRecord(
209 };163 };
210}164}
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
234function normalizeSavedEnvironmentRegistryDocument(166function normalizeSavedEnvironmentRegistryDocument(
235 document: SavedEnvironmentRegistryStorageDocument,167 document: SavedEnvironmentRegistryStorageDocument,
236): SavedEnvironmentRegistryDocument {168): SavedEnvironmentRegistryDocument {
...@@ -272,80 +204,6 @@ function readRegistryDocument(...@@ -272,80 +204,6 @@ function readRegistryDocument(
272 );204 );
273}205}
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
349function decodeSecretBytes(207function decodeSecretBytes(
350 environmentId: string,208 environmentId: string,
351 registryPath: string,209 registryPath: string,
...@@ -368,31 +226,8 @@ function decodeSecretBytes(...@@ -368,31 +226,8 @@ function decodeSecretBytes(
368export const make = Effect.gen(function* () {226export const make = Effect.gen(function* () {
369 const environment = yield* DesktopEnvironment.DesktopEnvironment;227 const environment = yield* DesktopEnvironment.DesktopEnvironment;
370 const fileSystem = yield* FileSystem.FileSystem;228 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) =>230 const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
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 );
396231
397 return DesktopSavedEnvironments.of({232 return DesktopSavedEnvironments.of({
398 getRegistry: readRegistryDocument(fileSystem, environment.savedEnvironmentRegistryPath).pipe(233 getRegistry: readRegistryDocument(fileSystem, environment.savedEnvironmentRegistryPath).pipe(
...@@ -401,30 +236,7 @@ export const make = Effect.gen(function* () {...@@ -401,30 +236,7 @@ export const make = Effect.gen(function* () {
401 ),236 ),
402 Effect.withSpan("desktop.savedEnvironments.getRegistry"),237 Effect.withSpan("desktop.savedEnvironments.getRegistry"),
403 ),238 ),
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 ),
428 getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) {240 getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) {
429 yield* Effect.annotateCurrentSpan({ environmentId });241 yield* Effect.annotateCurrentSpan({ environmentId });
430 const document = yield* readRegistryDocument(242 const document = yield* readRegistryDocument(
...@@ -472,85 +284,6 @@ export const make = Effect.gen(function* () {...@@ -472,85 +284,6 @@ export const make = Effect.gen(function* () {
472 ),284 ),
473 );285 );
474 }),286 }),
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 }),
554 });287 });
555});288});
556289
apps/desktop/src/shell/DesktopShellEnvironment.test.ts-10
...@@ -397,16 +397,6 @@ describe("DesktopShellEnvironment", () => {...@@ -397,16 +397,6 @@ describe("DesktopShellEnvironment", () => {
397 }),397 }),
398 );398 );
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
410 it.effect("logs command failures with safe probe context and the exact cause", () => {400 it.effect("logs command failures with safe probe context and the exact cause", () => {
411 const env: NodeJS.ProcessEnv = {401 const env: NodeJS.ProcessEnv = {
412 SHELL: "/bin/bash",402 SHELL: "/bin/bash",
apps/desktop/src/shell/DesktopShellEnvironment.ts-24
...@@ -122,30 +122,6 @@ const linuxRuntimeDirCandidates = (...@@ -122,30 +122,6 @@ const linuxRuntimeDirCandidates = (
122 return candidates.filter((candidate) => candidate.length > 0);122 return candidates.filter((candidate) => candidate.length > 0);
123};123};
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
149const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => {125const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => {
150 const normalized = entry.trim().replace(/^"+|"+$/g, "");126 const normalized = entry.trim().replace(/^"+|"+$/g, "");
151 return platform === "win32" ? normalized.toLowerCase() : normalized;127 return platform === "win32" ? normalized.toLowerCase() : normalized;
apps/desktop/src/ssh/DesktopSshEnvironment.ts-4
...@@ -45,10 +45,6 @@ export type DesktopSshEnvironmentOperationError =...@@ -45,10 +45,6 @@ export type DesktopSshEnvironmentOperationError =
4545
46export type DesktopSshEnvironmentDiscoverError = SshHostDiscoveryError;46export type DesktopSshEnvironmentDiscoverError = SshHostDiscoveryError;
4747
48export type DesktopSshEnvironmentError =
49 | DesktopSshEnvironmentDiscoverError
50 | DesktopSshEnvironmentOperationError;
51
52export class DesktopSshEnvironment extends Context.Service<48export class DesktopSshEnvironment extends Context.Service<
53 DesktopSshEnvironment,49 DesktopSshEnvironment,
54 {50 {
apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts-4
...@@ -164,10 +164,6 @@ export type DesktopSshPasswordPromptResolveError =...@@ -164,10 +164,6 @@ export type DesktopSshPasswordPromptResolveError =
164 | DesktopSshPromptInvalidRequestIdError164 | DesktopSshPromptInvalidRequestIdError
165 | DesktopSshPromptExpiredError;165 | DesktopSshPromptExpiredError;
166166
167export type DesktopSshPasswordPromptError =
168 | DesktopSshPasswordPromptRequestError
169 | DesktopSshPasswordPromptResolveError;
170
171export const DesktopSshPasswordPromptCancellation = Schema.Union([167export const DesktopSshPasswordPromptCancellation = Schema.Union([
172 DesktopSshPromptCancelledError,168 DesktopSshPromptCancelledError,
173 DesktopSshPromptWindowClosedError,169 DesktopSshPromptWindowClosedError,
apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts+4-5
...@@ -39,7 +39,6 @@ function makeElectronAppLayer(...@@ -39,7 +39,6 @@ function makeElectronAppLayer(
39 onMetricsRead();39 onMetricsRead();
40 return metrics;40 return metrics;
41 }),41 }),
42 isDefaultProtocolClient: () => Effect.succeed(false),
43 setAsDefaultProtocolClient: () => Effect.succeed(true),42 setAsDefaultProtocolClient: () => Effect.succeed(true),
44 setDesktopName: () => Effect.void,43 setDesktopName: () => Effect.void,
45 setDockIcon: () => Effect.void,44 setDockIcon: () => Effect.void,
...@@ -172,7 +171,7 @@ describe("DesktopTelemetryPublisher", () => {...@@ -172,7 +171,7 @@ describe("DesktopTelemetryPublisher", () => {
172171
173 const nextSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(Effect.forkChild);172 const nextSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(Effect.forkChild);
174 yield* Effect.yieldNow;173 yield* Effect.yieldNow;
175 yield* publisher.handleControl({174 yield* publisher.handleControlForSource("primary-backend", {
176 version: 1,175 version: 1,
177 type: "setDiagnosticsDemand",176 type: "setDiagnosticsDemand",
178 enabled: true,177 enabled: true,
...@@ -188,7 +187,7 @@ describe("DesktopTelemetryPublisher", () => {...@@ -188,7 +187,7 @@ describe("DesktopTelemetryPublisher", () => {
188 type: "setDiagnosticsDemand",187 type: "setDiagnosticsDemand",
189 enabled: true,188 enabled: true,
190 });189 });
191 yield* publisher.handleControl({190 yield* publisher.handleControlForSource("primary-backend", {
192 version: 1,191 version: 1,
193 type: "setDiagnosticsDemand",192 type: "setDiagnosticsDemand",
194 enabled: false,193 enabled: false,
...@@ -308,7 +307,7 @@ describe("DesktopTelemetryPublisher", () => {...@@ -308,7 +307,7 @@ describe("DesktopTelemetryPublisher", () => {
308 Effect.forkChild,307 Effect.forkChild,
309 );308 );
310 yield* Effect.yieldNow;309 yield* Effect.yieldNow;
311 yield* publisher.handleControl({310 yield* publisher.handleControlForSource("primary-backend", {
312 version: 1,311 version: 1,
313 type: "setHostPowerIntervals",312 type: "setHostPowerIntervals",
314 activeIntervalMs: 7_000,313 activeIntervalMs: 7_000,
...@@ -369,7 +368,7 @@ describe("DesktopTelemetryPublisher", () => {...@@ -369,7 +368,7 @@ describe("DesktopTelemetryPublisher", () => {
369 Effect.forkChild,368 Effect.forkChild,
370 );369 );
371 yield* Effect.yieldNow;370 yield* Effect.yieldNow;
372 yield* publisher.handleControl({371 yield* publisher.handleControlForSource("primary-backend", {
373 version: 1,372 version: 1,
374 type: "setDiagnosticsDemand",373 type: "setDiagnosticsDemand",
375 enabled: true,374 enabled: true,
apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts-4
...@@ -63,7 +63,6 @@ export class DesktopTelemetryPublisher extends Context.Service<...@@ -63,7 +63,6 @@ export class DesktopTelemetryPublisher extends Context.Service<
63 readonly latest: Effect.Effect<Option.Option<DesktopHostTelemetrySnapshot>>;63 readonly latest: Effect.Effect<Option.Option<DesktopHostTelemetrySnapshot>>;
64 readonly changes: Stream.Stream<DesktopHostTelemetrySnapshot>;64 readonly changes: Stream.Stream<DesktopHostTelemetrySnapshot>;
65 readonly encoded: Stream.Stream<Uint8Array>;65 readonly encoded: Stream.Stream<Uint8Array>;
66 readonly handleControl: (message: DesktopTelemetryControlMessage) => Effect.Effect<void>;
67 readonly handleControlForSource: (66 readonly handleControlForSource: (
68 sourceId: string,67 sourceId: string,
69 message: DesktopTelemetryControlMessage,68 message: DesktopTelemetryControlMessage,
...@@ -365,8 +364,6 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {...@@ -365,8 +364,6 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {
365 : Queue.offer(sampleTriggers, undefined).pipe(Effect.asVoid),364 : Queue.offer(sampleTriggers, undefined).pipe(Effect.asVoid),
366 ),365 ),
367 );366 );
368 const handleControl: DesktopTelemetryPublisher["Service"]["handleControl"] = (message) =>
369 handleControlForSource("legacy", message);
370367
371 const snapshots = Stream.unwrap(368 const snapshots = Stream.unwrap(
372 Effect.gen(function* () {369 Effect.gen(function* () {
...@@ -415,7 +412,6 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {...@@ -415,7 +412,6 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {
415 latest: Ref.get(latest),412 latest: Ref.get(latest),
416 changes: Stream.fromPubSub(changes),413 changes: Stream.fromPubSub(changes),
417 encoded,414 encoded,
418 handleControl,
419 handleControlForSource,415 handleControlForSource,
420 removeControlSource,416 removeControlSource,
421 publishUpdateReport,417 publishUpdateReport,
apps/desktop/src/updates/DesktopRemoteUpdates.test.ts-1
...@@ -57,7 +57,6 @@ function runRemoteUpdatesTest(...@@ -57,7 +57,6 @@ function runRemoteUpdatesTest(
57 latest: Effect.succeedNone,57 latest: Effect.succeedNone,
58 changes: Stream.empty,58 changes: Stream.empty,
59 encoded: Stream.empty,59 encoded: Stream.empty,
60 handleControl: () => Effect.void,
61 handleControlForSource: () => Effect.void,60 handleControlForSource: () => Effect.void,
62 removeControlSource: () => Effect.void,61 removeControlSource: () => Effect.void,
63 publishUpdateReport: (report) =>62 publishUpdateReport: (report) =>
apps/desktop/src/window/DesktopApplicationMenu.test.ts-1
...@@ -41,7 +41,6 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {...@@ -41,7 +41,6 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
41 setAboutPanelOptions: () => Effect.void,41 setAboutPanelOptions: () => Effect.void,
42 setAppUserModelId: () => Effect.void,42 setAppUserModelId: () => Effect.void,
43 getAppMetrics: Effect.succeed([]),43 getAppMetrics: Effect.succeed([]),
44 isDefaultProtocolClient: () => Effect.succeed(false),
45 setAsDefaultProtocolClient: () => Effect.succeed(true),44 setAsDefaultProtocolClient: () => Effect.succeed(true),
46 setDesktopName: () => Effect.void,45 setDesktopName: () => Effect.void,
47 setDockIcon: () => Effect.void,46 setDockIcon: () => Effect.void,
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift-20
...@@ -2001,19 +2001,6 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -2001,19 +2001,6 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
2001 )2001 )
2002 }2002 }
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
2017 private func fileStatusColor(_ changeType: String?) -> UIColor {2004 private func fileStatusColor(_ changeType: String?) -> UIColor {
2018 switch changeType {2005 switch changeType {
2019 case "new":2006 case "new":
...@@ -2027,13 +2014,6 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -2027,13 +2014,6 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
2027 }2014 }
2028 }2015 }
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
2037 private func drawFileIcon(rect: CGRect, changeType: String?) {2017 private func drawFileIcon(rect: CGRect, changeType: String?) {
2038 let color = fileStatusColor(changeType)2018 let color = fileStatusColor(changeType)
2039 let outerPath = UIBezierPath(roundedRect: rect, cornerRadius: 6)2019 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";...@@ -4,20 +4,13 @@ import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import { EnvironmentId } from "@t3tools/contracts";5import { EnvironmentId } from "@t3tools/contracts";
6import { RelayMobileClientId } from "@t3tools/contracts/relay";6import { RelayMobileClientId } from "@t3tools/contracts/relay";
7import { DPOP_UNKNOWN_HINT, ManagedRelay } from "@t3tools/client-runtime/relay";7import { ManagedRelay } from "@t3tools/client-runtime/relay";
8import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";8import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";
9import { HttpClient } from "effect/unstable/http";9import { HttpClient } from "effect/unstable/http";
10import { MobilePreferencesStore } from "../../persistence/mobile-preferences";10
11import { MobileStorage } from "../../persistence/mobile-storage";11import { MobileStorage } from "../../persistence/mobile-storage";
1212
13import {13import { linkEnvironmentToCloudWithPreference } from "./linkEnvironment";
14 linkEnvironmentToCloud,
15 linkEnvironmentToCloudWithPreference,
16 connectCloudEnvironment,
17 listCloudEnvironments,
18 listCloudEnvironmentsWithStatus,
19 refreshCloudEnvironmentConnection,
20} from "./linkEnvironment";
2114
22vi.mock("expo-constants", () => ({15vi.mock("expo-constants", () => ({
23 default: {16 default: {
...@@ -56,8 +49,6 @@ vi.mock("expo-secure-store", () => ({...@@ -56,8 +49,6 @@ vi.mock("expo-secure-store", () => ({
56 setItemAsync: vi.fn(),49 setItemAsync: vi.fn(),
57}));50}));
5851
59const loadPreferences = vi.fn(() => Effect.succeed({}));
60
61const savedConnection = {52const savedConnection = {
62 environmentId: EnvironmentId.make("env-1"),53 environmentId: EnvironmentId.make("env-1"),
63 environmentLabel: "Desktop",54 environmentLabel: "Desktop",
...@@ -68,8 +59,6 @@ const savedConnection = {...@@ -68,8 +59,6 @@ const savedConnection = {
68 bearerToken: "local-bearer",59 bearerToken: "local-bearer",
69};60};
7061
71const stableClerkToken = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyXzEyMyJ9.test";
72
73const createProofMock = vi.fn(62const createProofMock = vi.fn(
74 (input: { readonly method: string; readonly url: string; readonly accessToken?: string }) =>63 (input: { readonly method: string; readonly url: string; readonly accessToken?: string }) =>
75 Effect.succeed(`dpop:${input.method}:${input.url}`),64 Effect.succeed(`dpop:${input.method}:${input.url}`),
...@@ -86,14 +75,6 @@ function cloudClientLayer() {...@@ -86,14 +75,6 @@ function cloudClientLayer() {
86 const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init));75 const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init));
87 return Layer.mergeAll(76 return Layer.mergeAll(
88 httpClientLayer,77 httpClientLayer,
89 Layer.succeed(
90 MobilePreferencesStore,
91 MobilePreferencesStore.of({
92 load: loadPreferences(),
93 savePatch: (patch) => Effect.succeed(patch),
94 update: () => Effect.succeed({}),
95 }),
96 ),
97 Layer.succeed(78 Layer.succeed(
98 MobileStorage,79 MobileStorage,
99 MobileStorage.of({80 MobileStorage.of({
...@@ -123,7 +104,6 @@ const withCloudServices = <A, E>(...@@ -123,7 +104,6 @@ const withCloudServices = <A, E>(
123 | HttpClient.HttpClient104 | HttpClient.HttpClient
124 | ManagedRelay.ManagedRelayClient105 | ManagedRelay.ManagedRelayClient
125 | ManagedRelay.ManagedRelayDpopSigner106 | ManagedRelay.ManagedRelayDpopSigner
126 | MobilePreferencesStore
127 | MobileStorage107 | MobileStorage
128 >,108 >,
129) => effect.pipe(Effect.provide(cloudClientLayer()));109) => effect.pipe(Effect.provide(cloudClientLayer()));
...@@ -163,439 +143,12 @@ function requestBodyText(body: BodyInit | null | undefined): string {...@@ -163,439 +143,12 @@ function requestBodyText(body: BodyInit | null | undefined): string {
163 return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? "");143 return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? "");
164}144}
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
189describe("mobile cloud link environment client", () => {146describe("mobile cloud link environment client", () => {
190 beforeEach(() => {147 beforeEach(() => {
191 vi.restoreAllMocks();148 vi.restoreAllMocks();
192 createProofMock.mockClear();149 createProofMock.mockClear();
193 loadPreferences.mockClear();
194 });150 });
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
599 it.effect(152 it.effect(
600 "rejects relay link credentials for a different environment before persisting relay config",153 "rejects relay link credentials for a different environment before persisting relay config",
601 () =>154 () =>
...@@ -612,9 +165,10 @@ describe("mobile cloud link environment client", () => {...@@ -612,9 +165,10 @@ describe("mobile cloud link environment client", () => {
612 vi.stubGlobal("fetch", fetchMock);165 vi.stubGlobal("fetch", fetchMock);
613166
614 const error = yield* withCloudServices(167 const error = yield* withCloudServices(
615 linkEnvironmentToCloud({168 linkEnvironmentToCloudWithPreference({
616 clerkToken: "clerk-token",169 clerkToken: "clerk-token",
617 connection: savedConnection,170 connection: savedConnection,
171 liveActivitiesEnabled: true,
618 }),172 }),
619 ).pipe(Effect.flip);173 ).pipe(Effect.flip);
620 expect(error).toMatchObject({174 expect(error).toMatchObject({
...@@ -644,9 +198,10 @@ describe("mobile cloud link environment client", () => {...@@ -644,9 +198,10 @@ describe("mobile cloud link environment client", () => {
644 vi.stubGlobal("fetch", fetchMock);198 vi.stubGlobal("fetch", fetchMock);
645199
646 const error = yield* withCloudServices(200 const error = yield* withCloudServices(
647 linkEnvironmentToCloud({201 linkEnvironmentToCloudWithPreference({
648 clerkToken: "clerk-token",202 clerkToken: "clerk-token",
649 connection: savedConnection,203 connection: savedConnection,
204 liveActivitiesEnabled: true,
650 }),205 }),
651 ).pipe(Effect.flip);206 ).pipe(Effect.flip);
652 expect(error._tag).toBe("CloudEnvironmentLinkError");207 expect(error._tag).toBe("CloudEnvironmentLinkError");
...@@ -681,9 +236,10 @@ describe("mobile cloud link environment client", () => {...@@ -681,9 +236,10 @@ describe("mobile cloud link environment client", () => {
681 vi.stubGlobal("fetch", fetchMock);236 vi.stubGlobal("fetch", fetchMock);
682237
683 const error = yield* withCloudServices(238 const error = yield* withCloudServices(
684 linkEnvironmentToCloud({239 linkEnvironmentToCloudWithPreference({
685 clerkToken: "clerk-token",240 clerkToken: "clerk-token",
686 connection: savedConnection,241 connection: savedConnection,
242 liveActivitiesEnabled: true,
687 }),243 }),
688 ).pipe(Effect.flip);244 ).pipe(Effect.flip);
689 expect(error).toMatchObject({245 expect(error).toMatchObject({
...@@ -718,9 +274,10 @@ describe("mobile cloud link environment client", () => {...@@ -718,9 +274,10 @@ describe("mobile cloud link environment client", () => {
718 vi.stubGlobal("fetch", fetchMock);274 vi.stubGlobal("fetch", fetchMock);
719275
720 const error = yield* withCloudServices(276 const error = yield* withCloudServices(
721 linkEnvironmentToCloud({277 linkEnvironmentToCloudWithPreference({
722 clerkToken: "clerk-token",278 clerkToken: "clerk-token",
723 connection: savedConnection,279 connection: savedConnection,
280 liveActivitiesEnabled: true,
724 }),281 }),
725 ).pipe(Effect.flip);282 ).pipe(Effect.flip);
726 expect(error).toMatchObject({283 expect(error).toMatchObject({
...@@ -733,7 +290,6 @@ describe("mobile cloud link environment client", () => {...@@ -733,7 +290,6 @@ describe("mobile cloud link environment client", () => {
733290
734 it.effect("preserves disabled Live Activity preferences when linking an environment", () =>291 it.effect("preserves disabled Live Activity preferences when linking an environment", () =>
735 Effect.gen(function* () {292 Effect.gen(function* () {
736 loadPreferences.mockReturnValueOnce(Effect.succeed({ liveActivitiesEnabled: false }));
737 const bodies: Array<unknown> = [];293 const bodies: Array<unknown> = [];
738 const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => {294 const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => {
739 if (init?.body) {295 if (init?.body) {
...@@ -756,9 +312,10 @@ describe("mobile cloud link environment client", () => {...@@ -756,9 +312,10 @@ describe("mobile cloud link environment client", () => {
756 vi.stubGlobal("fetch", fetchMock);312 vi.stubGlobal("fetch", fetchMock);
757313
758 yield* withCloudServices(314 yield* withCloudServices(
759 linkEnvironmentToCloud({315 linkEnvironmentToCloudWithPreference({
760 clerkToken: "clerk-token",316 clerkToken: "clerk-token",
761 connection: savedConnection,317 connection: savedConnection,
318 liveActivitiesEnabled: false,
762 }),319 }),
763 );320 );
764321
...@@ -786,9 +343,8 @@ describe("mobile cloud link environment client", () => {...@@ -786,9 +343,8 @@ describe("mobile cloud link environment client", () => {
786 }),343 }),
787 );344 );
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", () =>
790 Effect.gen(function* () {347 Effect.gen(function* () {
791 loadPreferences.mockReturnValueOnce(Effect.die("persisted preferences must not be read"));
792 const bodies: Array<Record<string, unknown>> = [];348 const bodies: Array<Record<string, unknown>> = [];
793 const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => {349 const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => {
794 if (init?.body) {350 if (init?.body) {
...@@ -824,429 +380,4 @@ describe("mobile cloud link environment client", () => {...@@ -824,429 +380,4 @@ describe("mobile cloud link environment client", () => {
824 ]);380 ]);
825 }),381 }),
826 );382 );
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 );
1252});383});
apps/mobile/src/features/cloud/linkEnvironment.ts+3-340
...@@ -4,45 +4,24 @@ import * as Schema from "effect/Schema";...@@ -4,45 +4,24 @@ import * as Schema from "effect/Schema";
4import { HttpClient } from "effect/unstable/http";4import { HttpClient } from "effect/unstable/http";
5import {5import {
6 EnvironmentCloudEndpointUnavailableError,6 EnvironmentCloudEndpointUnavailableError,
7 EnvironmentAuthInvalidError,
8 EnvironmentHttpBadRequestError,7 EnvironmentHttpBadRequestError,
9 EnvironmentHttpConflictError,8 EnvironmentHttpConflictError,
10 EnvironmentHttpForbiddenError,9 EnvironmentHttpForbiddenError,
11 EnvironmentHttpInternalServerError,10 EnvironmentHttpInternalServerError,
12 EnvironmentHttpUnauthorizedError,11 EnvironmentHttpUnauthorizedError,
13} from "@t3tools/contracts";12} from "@t3tools/contracts";
14import { stripPairingTokenFromUrl } from "@t3tools/shared/remote";
15import {13import {
16 type RelayEnvironmentConnectResponse as RelayEnvironmentConnectResponseType,
17 type RelayEnvironmentLinkResponse as RelayEnvironmentLinkResponseType,14 type RelayEnvironmentLinkResponse as RelayEnvironmentLinkResponseType,
18 RelayEnvironmentConnectScope,
19 RelayEnvironmentStatusScope,
20 type RelayDpopAccessTokenScope,
21 type RelayClientEnvironmentRecord,
22 type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType,
23 type RelayManagedEndpointProviderKind,15 type RelayManagedEndpointProviderKind,
24} from "@t3tools/contracts/relay";16} from "@t3tools/contracts/relay";
25import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization";
26import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment";
27import { findErrorTraceId } from "@t3tools/client-runtime/errors";17import { findErrorTraceId } from "@t3tools/client-runtime/errors";
28import {18import { ManagedRelay, relayProtectedErrorMessage } from "@t3tools/client-runtime/relay";
29 dpopFailureMessage,
30 ManagedRelay,
31 relayProtectedErrorMessage,
32} from "@t3tools/client-runtime/relay";
33import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";19import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
3420
35import { authClientMetadata } from "../../lib/authClientMetadata";
36import type { SavedRemoteConnection } from "../../lib/connection";21import type { SavedRemoteConnection } from "../../lib/connection";
37import * as MobilePreferences from "../../persistence/mobile-preferences";
38import * as MobileStorage from "../../persistence/mobile-storage";22import * as MobileStorage from "../../persistence/mobile-storage";
39import { resolveCloudPublicConfig } from "./publicConfig";23import { resolveCloudPublicConfig } from "./publicConfig";
4024
41const RELAY_STATUS_AND_CONNECT_SCOPES = [
42 RelayEnvironmentStatusScope,
43 RelayEnvironmentConnectScope,
44] satisfies ReadonlyArray<RelayDpopAccessTokenScope>;
45
46function readRelayUrl(): string | null {25function readRelayUrl(): string | null {
47 return resolveCloudPublicConfig().relay.url;26 return resolveCloudPublicConfig().relay.url;
48}27}
...@@ -53,12 +32,6 @@ export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmen...@@ -53,12 +32,6 @@ export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmen
53 readonly traceId?: string;32 readonly traceId?: string;
54}> {}33}> {}
5534
56export interface CloudEnvironmentRecordWithStatus {
57 readonly environment: RelayClientEnvironmentRecord;
58 readonly status: RelayEnvironmentStatusResponseType | null;
59 readonly statusError: string | null;
60}
61
62const isEnvironmentCloudApiError = Schema.is(35const isEnvironmentCloudApiError = Schema.is(
63 Schema.Union([36 Schema.Union([
64 EnvironmentHttpBadRequestError,37 EnvironmentHttpBadRequestError,
...@@ -69,24 +42,19 @@ const isEnvironmentCloudApiError = Schema.is(...@@ -69,24 +42,19 @@ const isEnvironmentCloudApiError = Schema.is(
69 EnvironmentCloudEndpointUnavailableError,42 EnvironmentCloudEndpointUnavailableError,
70 ]),43 ]),
71);44);
72const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError);
7345
74const MANAGED_ENDPOINT_PROVIDER_KIND =46const MANAGED_ENDPOINT_PROVIDER_KIND =
75 "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind;47 "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind;
7648
77function cloudEnvironmentLinkError(message: string, options?: { readonly dpop?: boolean }) {49function cloudEnvironmentLinkError(message: string) {
78 return (cause: unknown) => {50 return (cause: unknown) => {
79 const environmentError = findEnvironmentCloudApiError(cause);51 const environmentError = findEnvironmentCloudApiError(cause);
80 const traceId = findErrorTraceId(cause);52 const traceId = findErrorTraceId(cause);
81 const dpopAuthError = options?.dpop ? findEnvironmentAuthInvalidError(cause) : null;
82 const detail = environmentError53 const detail = environmentError
83 ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}`54 ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}`
84 : withDevCause(message, cause);55 : withDevCause(message, cause);
85 return new CloudEnvironmentLinkError({56 return new CloudEnvironmentLinkError({
86 message:57 message: detail,
87 dpopAuthError?.reason === "invalid_credential"
88 ? dpopFailureMessage(detail, dpopAuthError.dpopFailureReason)
89 : detail,
90 cause,58 cause,
91 ...(traceId === null ? {} : { traceId }),59 ...(traceId === null ? {} : { traceId }),
92 });60 });
...@@ -143,16 +111,6 @@ function findEnvironmentCloudApiError(cause: unknown): { readonly message: strin...@@ -143,16 +111,6 @@ function findEnvironmentCloudApiError(cause: unknown): { readonly message: strin
143 return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null;111 return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null;
144}112}
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
156function requireRelayUrl(): Effect.Effect<string, CloudEnvironmentLinkError> {114function requireRelayUrl(): Effect.Effect<string, CloudEnvironmentLinkError> {
157 const relayUrl = readRelayUrl();115 const relayUrl = readRelayUrl();
158 return relayUrl116 return relayUrl
...@@ -186,54 +144,6 @@ function ensureLinkedEnvironmentMatches(input: {...@@ -186,54 +144,6 @@ function ensureLinkedEnvironmentMatches(input: {
186 return Effect.void;144 return Effect.void;
187}145}
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
237interface LinkEnvironmentToCloudInput {147interface LinkEnvironmentToCloudInput {
238 readonly connection: SavedRemoteConnection;148 readonly connection: SavedRemoteConnection;
239 readonly clerkToken: string;149 readonly clerkToken: string;
...@@ -328,250 +238,3 @@ export function linkEnvironmentToCloudWithPreference(...@@ -328,250 +238,3 @@ export function linkEnvironmentToCloudWithPreference(
328 );238 );
329 });239 });
330}240}
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 {...@@ -42,33 +42,6 @@ export function measureReviewWork<T>(name: string, callback: () => T): T {
42 }42 }
43}43}
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
72export function markReviewEvent(name: string, details?: Record<string, unknown>): void {45export function markReviewEvent(name: string, details?: Record<string, unknown>): void {
73 if (!isReviewPerfEnabled()) {46 if (!isReviewPerfEnabled()) {
74 return;47 return;
apps/mobile/src/features/review/shikiReviewHighlighter.ts+6-49
...@@ -15,14 +15,12 @@ import * as Schema from "effect/Schema";...@@ -15,14 +15,12 @@ import * as Schema from "effect/Schema";
15import {15import {
16 resolveReviewHighlighterEngine,16 resolveReviewHighlighterEngine,
17 resolveReviewHighlighterEnginePreference,17 resolveReviewHighlighterEnginePreference,
18 type ReviewHighlighterEngine,
19} from "./reviewHighlighterEngine";18} from "./reviewHighlighterEngine";
20import { createIncrementalSnippet } from "./incrementalSnippet";19import { createIncrementalSnippet } from "./incrementalSnippet";
21import type { ReviewRenderableLineRow } from "./reviewModel";20import type { ReviewRenderableLineRow } from "./reviewModel";
22import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs";21import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs";
2322
24export type ReviewDiffTheme = "light" | "dark";23export type ReviewDiffTheme = "light" | "dark";
25export type { ReviewHighlighterEngine };
2624
27export class ReviewHighlighterEngineInitializationError extends Schema.TaggedError<ReviewHighlighterEngineInitializationError>()(25export class ReviewHighlighterEngineInitializationError extends Schema.TaggedError<ReviewHighlighterEngineInitializationError>()(
28 "ReviewHighlighterEngineInitializationError",26 "ReviewHighlighterEngineInitializationError",
...@@ -181,7 +179,6 @@ const languageAliases: Record<string, string> = {...@@ -181,7 +179,6 @@ const languageAliases: Record<string, string> = {
181 txt: "text",179 txt: "text",
182};180};
183let highlighterPromise: Promise<HighlighterCore> | null = null;181let highlighterPromise: Promise<HighlighterCore> | null = null;
184let activeHighlighterEnginePromise: Promise<ReviewHighlighterEngine> | null = null;
185182
186type LoadedLanguageModule = {183type LoadedLanguageModule = {
187 default: Parameters<HighlighterCore["loadLanguage"]>[0];184 default: Parameters<HighlighterCore["loadLanguage"]>[0];
...@@ -254,10 +251,7 @@ async function getHighlighter(): Promise<HighlighterCore> {...@@ -254,10 +251,7 @@ async function getHighlighter(): Promise<HighlighterCore> {
254 engine: nativeEngineModule.createNativeEngine(),251 engine: nativeEngineModule.createNativeEngine(),
255 });252 });
256 logReviewHighlighterDiagnostic("using native engine");253 logReviewHighlighterDiagnostic("using native engine");
257 return {254 return highlighter;
258 highlighter,
259 engine: "native" as const,
260 };
261 }255 }
262 } catch (error) {256 } catch (error) {
263 nativeInitializationError = new ReviewHighlighterEngineInitializationError({257 nativeInitializationError = new ReviewHighlighterEngineInitializationError({
...@@ -308,55 +302,18 @@ async function getHighlighter(): Promise<HighlighterCore> {...@@ -308,55 +302,18 @@ async function getHighlighter(): Promise<HighlighterCore> {
308 logReviewHighlighterDiagnostic("using javascript engine", {302 logReviewHighlighterDiagnostic("using javascript engine", {
309 resolvedEngine: engine,303 resolvedEngine: engine,
310 });304 });
311 return {305 return highlighter;
312 highlighter,
313 engine,
314 };
315 })();306 })();
316307
317 highlighterPromise = configuredHighlighterPromise308 highlighterPromise = configuredHighlighterPromise.catch((error) => {
318 .then((result) => result.highlighter)309 highlighterPromise = null;
319 .catch((error) => {310 throw error;
320 highlighterPromise = null;311 });
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 });
330 }312 }
331313
332 return highlighterPromise;314 return highlighterPromise;
333}315}
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
360function resolveLanguageAlias(language: string): string {317function resolveLanguageAlias(language: string): string {
361 const normalized = language.toLowerCase();318 const normalized = language.toLowerCase();
362 return languageAliases[normalized] ?? normalized;319 return languageAliases[normalized] ?? normalized;
apps/server/integration/OrchestrationEngineHarness.integration.ts-9
...@@ -28,11 +28,9 @@ import * as CheckpointStore from "../src/checkpointing/CheckpointStore.ts";...@@ -28,11 +28,9 @@ import * as CheckpointStore from "../src/checkpointing/CheckpointStore.ts";
28import { TextGeneration } from "../src/textGeneration/TextGeneration.ts";28import { TextGeneration } from "../src/textGeneration/TextGeneration.ts";
29import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";29import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";
30import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts";30import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts";
31import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts";
32import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts";31import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts";
33import * as ProviderSessionRuntime from "../src/persistence/ProviderSessionRuntime.ts";32import * as ProviderSessionRuntime from "../src/persistence/ProviderSessionRuntime.ts";
34import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts";33import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts";
35import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts";
36import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts";34import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts";
37import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts";35import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts";
38import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";36import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";
...@@ -189,7 +187,6 @@ export interface OrchestrationIntegrationHarness {...@@ -189,7 +187,6 @@ export interface OrchestrationIntegrationHarness {
189 readonly snapshotQuery: ProjectionSnapshotQuery["Service"];187 readonly snapshotQuery: ProjectionSnapshotQuery["Service"];
190 readonly providerService: ProviderService["Service"];188 readonly providerService: ProviderService["Service"];
191 readonly checkpointStore: CheckpointStore.CheckpointStore["Service"];189 readonly checkpointStore: CheckpointStore.CheckpointStore["Service"];
192 readonly checkpointRepository: ProjectionCheckpointRepository["Service"];
193 readonly pendingApprovalRepository: ProjectionPendingApprovalRepository["Service"];190 readonly pendingApprovalRepository: ProjectionPendingApprovalRepository["Service"];
194 readonly waitForThread: (191 readonly waitForThread: (
195 threadId: string,192 threadId: string,
...@@ -313,7 +310,6 @@ export const makeOrchestrationIntegrationHarness = (...@@ -313,7 +310,6 @@ export const makeOrchestrationIntegrationHarness = (
313 const runtimeServicesLayer = Layer.mergeAll(310 const runtimeServicesLayer = Layer.mergeAll(
314 projectionSnapshotQueryLayer,311 projectionSnapshotQueryLayer,
315 orchestrationLayer.pipe(Layer.provide(projectionSnapshotQueryLayer)),312 orchestrationLayer.pipe(Layer.provide(projectionSnapshotQueryLayer)),
316 ProjectionCheckpointRepositoryLive,
317 ProjectionPendingApprovalRepositoryLive,313 ProjectionPendingApprovalRepositoryLive,
318 checkpointStoreLayer,314 checkpointStoreLayer,
319 providerLayer,315 providerLayer,
...@@ -463,10 +459,6 @@ export const makeOrchestrationIntegrationHarness = (...@@ -463,10 +459,6 @@ export const makeOrchestrationIntegrationHarness = (
463 const checkpointStore = yield* tryRuntimePromise("load CheckpointStore service", () =>459 const checkpointStore = yield* tryRuntimePromise("load CheckpointStore service", () =>
464 runtime.runPromise(Effect.service(CheckpointStore.CheckpointStore)),460 runtime.runPromise(Effect.service(CheckpointStore.CheckpointStore)),
465 ).pipe(Effect.orDie);461 ).pipe(Effect.orDie);
466 const checkpointRepository = yield* tryRuntimePromise(
467 "load ProjectionCheckpointRepository service",
468 () => runtime.runPromise(Effect.service(ProjectionCheckpointRepository)),
469 ).pipe(Effect.orDie);
470 const pendingApprovalRepository = yield* tryRuntimePromise(462 const pendingApprovalRepository = yield* tryRuntimePromise(
471 "load ProjectionPendingApprovalRepository service",463 "load ProjectionPendingApprovalRepository service",
472 () => runtime.runPromise(Effect.service(ProjectionPendingApprovalRepository)),464 () => runtime.runPromise(Effect.service(ProjectionPendingApprovalRepository)),
...@@ -612,7 +604,6 @@ export const makeOrchestrationIntegrationHarness = (...@@ -612,7 +604,6 @@ export const makeOrchestrationIntegrationHarness = (
612 snapshotQuery,604 snapshotQuery,
613 providerService,605 providerService,
614 checkpointStore,606 checkpointStore,
615 checkpointRepository,
616 pendingApprovalRepository,607 pendingApprovalRepository,
617 waitForThread,608 waitForThread,
618 waitForDomainEvent,609 waitForDomainEvent,
apps/server/integration/orchestrationEngine.integration.test.ts+3-26
...@@ -20,7 +20,6 @@ import {...@@ -20,7 +20,6 @@ import {
20import { assert, it } from "@effect/vitest";20import { assert, it } from "@effect/vitest";
21import * as Clock from "effect/Clock";21import * as Clock from "effect/Clock";
22import * as Effect from "effect/Effect";22import * as Effect from "effect/Effect";
23import * as Option from "effect/Option";
24import * as Schema from "effect/Schema";23import * as Schema from "effect/Schema";
2524
26import type { TestTurnResponse } from "./TestProviderAdapter.integration.ts";25import 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 +...@@ -247,14 +246,7 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite +
247 );246 );
248 assert.equal(thread.checkpoints[0]?.status, "ready");247 assert.equal(thread.checkpoints[0]?.status, "ready");
249 assert.equal(thread.checkpoints[0]?.checkpointTurnCount, 1);248 assert.equal(thread.checkpoints[0]?.checkpointTurnCount, 1);
250249 assert.deepEqual(thread.checkpoints[0]?.files, []);
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, []);
258250
259 const ref0 = checkpointRefForThreadTurn(THREAD_ID, 0);251 const ref0 = checkpointRefForThreadTurn(THREAD_ID, 0);
260 const ref1 = checkpointRefForThreadTurn(THREAD_ID, 1);252 const ref1 = checkpointRefForThreadTurn(THREAD_ID, 1);
...@@ -498,11 +490,8 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>...@@ -498,11 +490,8 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
498 true,490 true,
499 );491 );
500492
501 const checkpointRows = yield* harness.checkpointRepository.listByThreadId({
502 threadId: THREAD_ID,
503 });
504 assert.deepEqual(493 assert.deepEqual(
505 checkpointRows.map((row) => row.checkpointTurnCount),494 secondTurnThread.checkpoints.map((row) => row.checkpointTurnCount),
506 [1, 2],495 [1, 2],
507 );496 );
508497
...@@ -688,14 +677,7 @@ it.live("records failed turn runtime state and checkpoint status as error", () =...@@ -688,14 +677,7 @@ it.live("records failed turn runtime state and checkpoint status as error", () =
688 assert.equal(thread.session?.status, "error");677 assert.equal(thread.session?.status, "error");
689 assert.equal(thread.checkpoints[0]?.status, "error");678 assert.equal(thread.checkpoints[0]?.status, "error");
690679
691 const checkpointRow = yield* harness.checkpointRepository.getByThreadAndTurnCount({680 assert.equal(thread.checkpoints[0]?.checkpointTurnCount, 1);
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 }
699 assert.equal(681 assert.equal(
700 gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 1)),682 gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 1)),
701 true,683 true,
...@@ -878,11 +860,6 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git...@@ -878,11 +860,6 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
878 false,860 false,
879 );861 );
880 assert.deepEqual(harness.adapterHarness!.getRollbackCalls(THREAD_ID), [1]);862 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);
886 }),863 }),
887 ),864 ),
888);865);
apps/server/src/orchestration/Errors.ts+1-44
...@@ -4,30 +4,6 @@ import * as Schema from "effect/Schema";...@@ -4,30 +4,6 @@ import * as Schema from "effect/Schema";
44
5import type { ProjectionRepositoryError } from "../persistence/Errors.ts";5import 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
31export class OrchestrationCommandInvariantError extends Schema.TaggedError<OrchestrationCommandInvariantError>()(7export class OrchestrationCommandInvariantError extends Schema.TaggedError<OrchestrationCommandInvariantError>()(
32 "OrchestrationCommandInvariantError",8 "OrchestrationCommandInvariantError",
33 {9 {
...@@ -100,31 +76,12 @@ export class OrchestrationProjectorDecodeError extends Schema.TaggedError<Orches...@@ -100,31 +76,12 @@ export class OrchestrationProjectorDecodeError extends Schema.TaggedError<Orches
100 }76 }
101}77}
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
116export type OrchestrationDispatchError =79export type OrchestrationDispatchError =
117 | ProjectionRepositoryError80 | ProjectionRepositoryError
118 | OrchestrationCommandRejection81 | OrchestrationCommandRejection
119 | OrchestrationCommandIdConflictError82 | OrchestrationCommandIdConflictError
120 | OrchestrationCommandPreviouslyRejectedError83 | OrchestrationCommandPreviouslyRejectedError
121 | OrchestrationProjectorDecodeError84 | OrchestrationProjectorDecodeError;
122 | OrchestrationListenerCallbackError;
123
124export type OrchestrationEngineError =
125 | OrchestrationDispatchError
126 | OrchestrationCommandJsonParseError
127 | OrchestrationCommandDecodeError;
12885
129export function toProjectorDecodeError(eventType: string) {86export function toProjectorDecodeError(eventType: string) {
130 return (error: Schema.SchemaError): OrchestrationProjectorDecodeError =>87 return (error: Schema.SchemaError): OrchestrationProjectorDecodeError =>
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+11-6
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8 MessageId,8 MessageId,
9 NonNegativeInt,9 NonNegativeInt,
10 OrchestrationCheckpointFile,10 OrchestrationCheckpointFile,
11 OrchestrationCheckpointStatus,
11 OrchestrationProposedPlanId,12 OrchestrationProposedPlanId,
12 OrchestrationReadModel,13 OrchestrationReadModel,
13 OrchestrationThreadSearchSource,14 OrchestrationThreadSearchSource,
...@@ -52,7 +53,6 @@ import {...@@ -52,7 +53,6 @@ import {
52 toPersistenceSqlError,53 toPersistenceSqlError,
53 type ProjectionRepositoryError,54 type ProjectionRepositoryError,
54} from "../../persistence/Errors.ts";55} from "../../persistence/Errors.ts";
55import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts";
56import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts";56import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts";
57import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts";57import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts";
58import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts";58import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts";
...@@ -151,11 +151,16 @@ const ProjectionThreadRuntimeContextDbRowSchema = Schema.Struct({...@@ -151,11 +151,16 @@ const ProjectionThreadRuntimeContextDbRowSchema = Schema.Struct({
151 title: Schema.String,151 title: Schema.String,
152 session: Schema.NullOr(ProjectionThreadSessionDbRowSchema),152 session: Schema.NullOr(ProjectionThreadSessionDbRowSchema),
153});153});
154const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields(154const ProjectionCheckpointDbRowSchema = Schema.Struct({
155 Struct.assign({155 threadId: ThreadId,
156 files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)),156 turnId: TurnId,
157 }),157 checkpointTurnCount: NonNegativeInt,
158);158 checkpointRef: CheckpointRef,
159 status: OrchestrationCheckpointStatus,
160 files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)),
161 assistantMessageId: Schema.NullOr(MessageId),
162 completedAt: IsoDateTime,
163});
159const ProjectionLatestTurnDbRowSchema = Schema.Struct({164const ProjectionLatestTurnDbRowSchema = Schema.Struct({
160 threadId: ProjectionThread.fields.threadId,165 threadId: ProjectionThread.fields.threadId,
161 turnId: TurnId,166 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", () =>...@@ -42,7 +42,7 @@ it.effect("names a real SQLite condition without copying query data", () =>
42 assert.equal(error.cause, cause);42 assert.equal(error.cause, cause);
43 assert.notInclude(error.message, payload);43 assert.notInclude(error.message, payload);
44 assert.notInclude(error.message, "private_column");44 assert.notInclude(error.message, "private_column");
45 }).pipe(Effect.provide(NodeSqliteClient.layerMemory())),45 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
46);46);
4747
48it("reads the condition through a wrapping driver error", () => {48it("reads the condition through a wrapping driver error", () => {
apps/server/src/persistence/Errors.ts-34
...@@ -124,42 +124,8 @@ export function toPersistenceDecodeError(operation: string) {...@@ -124,42 +124,8 @@ export function toPersistenceDecodeError(operation: string) {
124export const isPersistenceError = (u: unknown) =>124export const isPersistenceError = (u: unknown) =>
125 isPersistenceSqlError(u) || isPersistenceDecodeError(u);125 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
157export type OrchestrationEventStoreError = PersistenceSqlError | PersistenceDecodeError;127export type OrchestrationEventStoreError = PersistenceSqlError | PersistenceDecodeError;
158128
159export type ProviderSessionRepositoryError =
160 | ProviderSessionRepositoryValidationError
161 | ProviderSessionRepositoryPersistenceError;
162
163export type OrchestrationCommandReceiptRepositoryError =129export type OrchestrationCommandReceiptRepositoryError =
164 | PersistenceSqlError130 | PersistenceSqlError
165 | PersistenceDecodeError;131 | 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";...@@ -7,7 +7,6 @@ import * as Schema from "effect/Schema";
7import { toPersistenceSqlError } from "../Errors.ts";7import { toPersistenceSqlError } from "../Errors.ts";
8import {8import {
9 GetProjectionPendingApprovalInput,9 GetProjectionPendingApprovalInput,
10 DeleteProjectionPendingApprovalInput,
11 ListProjectionPendingApprovalsInput,10 ListProjectionPendingApprovalsInput,
12 ProjectionPendingApproval,11 ProjectionPendingApproval,
13 ProjectionPendingApprovalRepository,12 ProjectionPendingApprovalRepository,
...@@ -97,15 +96,6 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () {...@@ -97,15 +96,6 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () {
97 `,96 `,
98 });97 });
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
109 const deleteProjectionPendingApprovalRowsByThread = SqlSchema.void({99 const deleteProjectionPendingApprovalRowsByThread = SqlSchema.void({
110 Request: ListProjectionPendingApprovalsInput,100 Request: ListProjectionPendingApprovalsInput,
111 execute: ({ threadId }) =>101 execute: ({ threadId }) =>
...@@ -143,15 +133,6 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () {...@@ -143,15 +133,6 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () {
143 ),133 ),
144 );134 );
145135
146 const deleteByRequestId: ProjectionPendingApprovalRepositoryShape["deleteByRequestId"] = (
147 input,
148 ) =>
149 deleteProjectionPendingApprovalRow(input).pipe(
150 Effect.mapError(
151 toPersistenceSqlError("ProjectionPendingApprovalRepository.deleteByRequestId:query"),
152 ),
153 );
154
155 const deleteByThreadId: ProjectionPendingApprovalRepositoryShape["deleteByThreadId"] = (input) =>136 const deleteByThreadId: ProjectionPendingApprovalRepositoryShape["deleteByThreadId"] = (input) =>
156 deleteProjectionPendingApprovalRowsByThread(input).pipe(137 deleteProjectionPendingApprovalRowsByThread(input).pipe(
157 Effect.mapError(138 Effect.mapError(
...@@ -164,7 +145,6 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () {...@@ -164,7 +145,6 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () {
164 listByThreadId,145 listByThreadId,
165 countPendingByThreadId,146 countPendingByThreadId,
166 getByRequestId,147 getByRequestId,
167 deleteByRequestId,
168 deleteByThreadId,148 deleteByThreadId,
169 } satisfies ProjectionPendingApprovalRepositoryShape;149 } satisfies ProjectionPendingApprovalRepositoryShape;
170});150});
apps/server/src/persistence/Layers/ProjectionProjects.ts-47
...@@ -9,7 +9,6 @@ import * as Struct from "effect/Struct";...@@ -9,7 +9,6 @@ import * as Struct from "effect/Struct";
9import { ModelSelection, ProjectIconOverride, ProjectScript } from "@t3tools/contracts";9import { ModelSelection, ProjectIconOverride, ProjectScript } from "@t3tools/contracts";
10import { toPersistenceSqlError } from "../Errors.ts";10import { toPersistenceSqlError } from "../Errors.ts";
11import {11import {
12 DeleteProjectionProjectInput,
13 GetProjectionProjectInput,12 GetProjectionProjectInput,
14 ProjectionProject,13 ProjectionProject,
15 ProjectionProjectRepository,14 ProjectionProjectRepository,
...@@ -24,7 +23,6 @@ const ProjectionProjectDbRow = ProjectionProject.mapFields(...@@ -24,7 +23,6 @@ const ProjectionProjectDbRow = ProjectionProject.mapFields(
24 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),23 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
25 }),24 }),
26);25);
27type ProjectionProjectDbRow = typeof ProjectionProjectDbRow.Type;
2826
29const makeProjectionProjectRepository = Effect.gen(function* () {27const makeProjectionProjectRepository = Effect.gen(function* () {
30 const sql = yield* SqlClient.SqlClient;28 const sql = yield* SqlClient.SqlClient;
...@@ -100,38 +98,6 @@ const makeProjectionProjectRepository = Effect.gen(function* () {...@@ -100,38 +98,6 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
100 `,98 `,
101 });99 });
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
135 const upsert: ProjectionProjectRepositoryShape["upsert"] = (row) =>101 const upsert: ProjectionProjectRepositoryShape["upsert"] = (row) =>
136 upsertProjectionProjectRow(row).pipe(102 upsertProjectionProjectRow(row).pipe(
137 Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.upsert:query")),103 Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.upsert:query")),
...@@ -143,22 +109,9 @@ const makeProjectionProjectRepository = Effect.gen(function* () {...@@ -143,22 +109,9 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
143 Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.getById:query")),109 Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.getById:query")),
144 );110 );
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
157 return {112 return {
158 upsert,113 upsert,
159 getById,114 getById,
160 listAll,
161 deleteById,
162 } satisfies ProjectionProjectRepositoryShape;115 } satisfies ProjectionProjectRepositoryShape;
163});116});
164117
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts-3
...@@ -518,9 +518,6 @@ projectionRepositoriesLayer("Projection repositories", (it) => {...@@ -518,9 +518,6 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
518 assert.deepStrictEqual(Option.getOrNull(persisted)?.linkedPullRequest, linkedPullRequest);518 assert.deepStrictEqual(Option.getOrNull(persisted)?.linkedPullRequest, linkedPullRequest);
519 assert.deepStrictEqual(Option.getOrNull(persisted)?.branchPullRequest, branchPullRequest);519 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
524 const row = Option.getOrNull(persisted);521 const row = Option.getOrNull(persisted);
525 if (row === null) return yield* Effect.die("Expected linked thread row to exist.");522 if (row === null) return yield* Effect.die("Expected linked thread row to exist.");
526 yield* threads.upsert({ ...row, linkedPullRequest: null });523 yield* threads.upsert({ ...row, linkedPullRequest: null });
apps/server/src/persistence/Layers/ProjectionState.ts-25
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1import { NonNegativeInt } from "@t3tools/contracts";
2import * as SqlClient from "effect/unstable/sql/SqlClient";1import * as SqlClient from "effect/unstable/sql/SqlClient";
3import * as SqlSchema from "effect/unstable/sql/SqlSchema";2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
4import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
...@@ -14,10 +13,6 @@ import {...@@ -14,10 +13,6 @@ import {
14 ProjectionState,13 ProjectionState,
15} from "../Services/ProjectionState.ts";14} from "../Services/ProjectionState.ts";
1615
17const MinLastAppliedSequenceRowSchema = Schema.Struct({
18 minLastAppliedSequence: Schema.NullOr(NonNegativeInt),
19});
20
21const makeProjectionStateRepository = Effect.gen(function* () {16const makeProjectionStateRepository = Effect.gen(function* () {
22 const sql = yield* SqlClient.SqlClient;17 const sql = yield* SqlClient.SqlClient;
2318
...@@ -90,17 +85,6 @@ const makeProjectionStateRepository = Effect.gen(function* () {...@@ -90,17 +85,6 @@ const makeProjectionStateRepository = Effect.gen(function* () {
90 `,85 `,
91 });86 });
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
104 const upsert: ProjectionStateRepositoryShape["upsert"] = (row) =>88 const upsert: ProjectionStateRepositoryShape["upsert"] = (row) =>
105 upsertProjectionStateRow(row).pipe(89 upsertProjectionStateRow(row).pipe(
106 Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.upsert:query")),90 Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.upsert:query")),
...@@ -121,20 +105,11 @@ const makeProjectionStateRepository = Effect.gen(function* () {...@@ -121,20 +105,11 @@ const makeProjectionStateRepository = Effect.gen(function* () {
121 Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.listAll:query")),105 Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.listAll:query")),
122 );106 );
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
132 return {108 return {
133 upsert,109 upsert,
134 upsertMany,110 upsertMany,
135 getByProjector,111 getByProjector,
136 listAll,112 listAll,
137 minLastAppliedSequence,
138 } satisfies ProjectionStateRepositoryShape;113 } satisfies ProjectionStateRepositoryShape;
139});114});
140115
apps/server/src/persistence/Layers/ProjectionThreads.ts-66
...@@ -7,9 +7,7 @@ import * as Struct from "effect/Struct";...@@ -7,9 +7,7 @@ import * as Struct from "effect/Struct";
77
8import { toPersistenceSqlError } from "../Errors.ts";8import { toPersistenceSqlError } from "../Errors.ts";
9import {9import {
10 DeleteProjectionThreadInput,
11 GetProjectionThreadInput,10 GetProjectionThreadInput,
12 ListProjectionThreadsByProjectInput,
13 ProjectionThread,11 ProjectionThread,
14 ProjectionThreadRepository,12 ProjectionThreadRepository,
15 type ProjectionThreadRepositoryShape,13 type ProjectionThreadRepositoryShape,
...@@ -24,7 +22,6 @@ const ProjectionThreadDbRow = ProjectionThread.mapFields(...@@ -24,7 +22,6 @@ const ProjectionThreadDbRow = ProjectionThread.mapFields(
24 branchPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)),22 branchPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)),
25 }),23 }),
26);24);
27type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type;
2825
29const makeProjectionThreadRepository = Effect.gen(function* () {26const makeProjectionThreadRepository = Effect.gen(function* () {
30 const sql = yield* SqlClient.SqlClient;27 const sql = yield* SqlClient.SqlClient;
...@@ -172,57 +169,6 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -172,57 +169,6 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
172 `,169 `,
173 });170 });
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
226 const upsert: ProjectionThreadRepositoryShape["upsert"] = (row) =>172 const upsert: ProjectionThreadRepositoryShape["upsert"] = (row) =>
227 upsertProjectionThreadRow(row).pipe(173 upsertProjectionThreadRow(row).pipe(
228 Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.upsert:query")),174 Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.upsert:query")),
...@@ -233,21 +179,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -233,21 +179,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
233 Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.getById:query")),179 Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.getById:query")),
234 );180 );
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
246 return {182 return {
247 upsert,183 upsert,
248 getById,184 getById,
249 listByProjectId,
250 deleteById,
251 } satisfies ProjectionThreadRepositoryShape;185 } satisfies ProjectionThreadRepositoryShape;
252});186});
253187
apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts+1-1
...@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";...@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";7import * 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
11layer("016_CanonicalizeModelSelections", (it) => {11layer("016_CanonicalizeModelSelections", (it) => {
12 it.effect(12 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";...@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";7import * 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
11layer("019_ProjectionSnapshotLookupIndexes", (it) => {11layer("019_ProjectionSnapshotLookupIndexes", (it) => {
12 it.effect("creates indexes for targeted projection lookup filters", () =>12 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";...@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";7import * 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
11layer("024_BackfillProjectionThreadShellSummary", (it) => {11layer("024_BackfillProjectionThreadShellSummary", (it) => {
12 it.effect("backfills thread shell summary fields and clears stale projected approvals", () =>12 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";...@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";7import * 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
11layer("025_CleanupInvalidProjectionPendingApprovals", (it) => {11layer("025_CleanupInvalidProjectionPendingApprovals", (it) => {
12 it.effect("removes pending-approval rows that do not come from approval requests", () =>12 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";...@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";7import * 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
11layer("026_CanonicalizeModelSelectionOptions", (it) => {11layer("026_CanonicalizeModelSelectionOptions", (it) => {
12 it.effect("converts legacy object-shape options into array-shape on projections and events", () =>12 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";...@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";7import * 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
11layer("027_028_ProviderInstanceIdColumns", (it) => {11layer("027_028_ProviderInstanceIdColumns", (it) => {
12 it.effect("continues when provider_session_runtime was partially migrated", () =>12 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";...@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";7import * 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
11layer("029_ProjectionThreadDetailOrderingIndexes", (it) => {11layer("029_ProjectionThreadDetailOrderingIndexes", (it) => {
12 it.effect("creates indexes matching thread detail ordering queries", () =>12 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";...@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";7import * 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
11layer("031_AuthAuthorizationScopes", (it) => {11layer("031_AuthAuthorizationScopes", (it) => {
12 it.effect("invalidates role-based auth records and installs scoped auth tables", () =>12 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";...@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";7import * 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
11layer("035_ProjectionThreadTitleRegeneration", (it) => {11layer("035_ProjectionThreadTitleRegeneration", (it) => {
12 it.effect("adds pending title regeneration columns", () =>12 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";...@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";7import * 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
11layer("040_ProjectionProjectFaviconPath", (it) => {11layer("040_ProjectionProjectFaviconPath", (it) => {
12 it.effect("adds the nullable favicon path to project projections", () =>12 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";...@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";7import * 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
11layer("041_AuthSessionClientConnection", (it) => {11layer("041_AuthSessionClientConnection", (it) => {
12 it.effect("adds nullable client surface and app version columns to auth sessions", () =>12 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";...@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";7import * 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
11layer("042_ProjectionThreadLinkedPullRequest", (it) => {11layer("042_ProjectionThreadLinkedPullRequest", (it) => {
12 it.effect("adds the linked pull request column", () =>12 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";...@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";7import * 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
11const MODEL_SELECTION = '{"instanceId":"codex","model":"gpt-5.6-sol"}';11const 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";...@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";7import * 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
11layer("047_ProjectionProjectIcon", (it) => {11layer("047_ProjectionProjectIcon", (it) => {
12 it.effect("adds the nullable project icon JSON to project projections", () =>12 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";...@@ -6,13 +6,15 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import migrateActiveOrderKey from "./049_ProjectionThreadsActiveOrderKey.ts";7import migrateActiveOrderKey from "./049_ProjectionThreadsActiveOrderKey.ts";
88
9it.layer(NodeSqliteClient.layerMemory())("049_ProjectionThreadsActiveOrderKey", (it) => {9it.layer(NodeSqliteClient.layer({ filename: ":memory:" }))(
10 it.effect("migrates old threads without changing their timestamps or assigning an order", () =>10 "049_ProjectionThreadsActiveOrderKey",
11 Effect.gen(function* () {11 (it) => {
12 const sql = yield* SqlClient.SqlClient;12 it.effect("migrates old threads without changing their timestamps or assigning an order", () =>
13 yield* runMigrations({ toMigrationInclusive: 48 });13 Effect.gen(function* () {
14 const now = "2026-01-01T00:00:00.000Z";14 const sql = yield* SqlClient.SqlClient;
15 yield* sql`15 yield* runMigrations({ toMigrationInclusive: 48 });
16 const now = "2026-01-01T00:00:00.000Z";
17 yield* sql`
16 INSERT INTO projection_threads (18 INSERT INTO projection_threads (
17 thread_id, project_id, title, model_selection_json, runtime_mode,19 thread_id, project_id, title, model_selection_json, runtime_mode,
18 created_at, updated_at20 created_at, updated_at
...@@ -21,24 +23,25 @@ it.layer(NodeSqliteClient.layerMemory())("049_ProjectionThreadsActiveOrderKey",...@@ -21,24 +23,25 @@ it.layer(NodeSqliteClient.layerMemory())("049_ProjectionThreadsActiveOrderKey",
21 '{"instanceId":"codex","model":"gpt-5.4"}', 'full-access', ${now}, ${now}23 '{"instanceId":"codex","model":"gpt-5.4"}', 'full-access', ${now}, ${now}
22 )24 )
23 `;25 `;
24 yield* runMigrations({ toMigrationInclusive: 49 });26 yield* runMigrations({ toMigrationInclusive: 49 });
25 const migrated = yield* sql<{ readonly activeOrderKey: string | null }>`27 const migrated = yield* sql<{ readonly activeOrderKey: string | null }>`
26 SELECT active_order_key AS "activeOrderKey" FROM projection_threads WHERE thread_id = 'thread-1'28 SELECT active_order_key AS "activeOrderKey" FROM projection_threads WHERE thread_id = 'thread-1'
27 `;29 `;
28 assert.deepEqual(migrated, [{ activeOrderKey: null }]);30 assert.deepEqual(migrated, [{ activeOrderKey: null }]);
29 // Recovery may run the same migration against a database that already31 // Recovery may run the same migration against a database that already
30 // has the column, including a placement written after the upgrade.32 // 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'`;33 yield* sql`UPDATE projection_threads SET active_order_key = 'gm' WHERE thread_id = 'thread-1'`;
32 yield* migrateActiveOrderKey;34 yield* migrateActiveOrderKey;
33 const rows = yield* sql<{35 const rows = yield* sql<{
34 readonly activeOrderKey: string | null;36 readonly activeOrderKey: string | null;
35 readonly createdAt: string;37 readonly createdAt: string;
36 readonly updatedAt: string;38 readonly updatedAt: string;
37 }>`39 }>`
38 SELECT active_order_key AS "activeOrderKey", created_at AS "createdAt", updated_at AS "updatedAt"40 SELECT active_order_key AS "activeOrderKey", created_at AS "createdAt", updated_at AS "updatedAt"
39 FROM projection_threads WHERE thread_id = 'thread-1'41 FROM projection_threads WHERE thread_id = 'thread-1'
40 `;42 `;
41 assert.deepEqual(rows, [{ activeOrderKey: "gm", createdAt: now, updatedAt: now }]);43 assert.deepEqual(rows, [{ activeOrderKey: "gm", createdAt: now, updatedAt: now }]);
42 }),44 }),
43 );45 );
44});46 },
47);
apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.test.ts+22-19
...@@ -9,7 +9,7 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";...@@ -9,7 +9,7 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
99
10const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));10const 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
14interface PullRequestRow {14interface PullRequestRow {
15 readonly threadId: string;15 readonly threadId: string;
...@@ -156,26 +156,29 @@ layer("050_ProjectionThreadPullRequests", (it) => {...@@ -156,26 +156,29 @@ layer("050_ProjectionThreadPullRequests", (it) => {
156 );156 );
157});157});
158158
159it.layer(Layer.fresh(NodeSqliteClient.layerMemory()))("050 Azure legacy links", (it) => {159it.layer(Layer.fresh(NodeSqliteClient.layer({ filename: ":memory:" })))(
160 it.effect("keeps legacy Azure repositories distinct across organizations", () =>160 "050 Azure legacy links",
161 Effect.gen(function* () {161 (it) => {
162 const sql = yield* SqlClient.SqlClient;162 it.effect("keeps legacy Azure repositories distinct across organizations", () =>
163 yield* runMigrations({ toMigrationInclusive: 49 });163 Effect.gen(function* () {
164 for (const organization of ["org-a", "org-b"]) {164 const sql = yield* SqlClient.SqlClient;
165 yield* sql`165 yield* runMigrations({ toMigrationInclusive: 49 });
166 for (const organization of ["org-a", "org-b"]) {
167 yield* sql`
166 INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, linked_pull_request_json, created_at, updated_at)168 INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, linked_pull_request_json, created_at, updated_at)
167 VALUES (${organization}, ${organization}, 'Azure', '{"instanceId":"codex","model":"gpt-5.4"}',169 VALUES (${organization}, ${organization}, 'Azure', '{"instanceId":"codex","model":"gpt-5.4"}',
168 ${encodeJson({ projectId: organization, repository: "web", number: 7, url: `https://dev.azure.com/${organization}/project/_git/web/pullrequest/7` })},170 ${encodeJson({ projectId: organization, repository: "web", number: 7, url: `https://dev.azure.com/${organization}/project/_git/web/pullrequest/7` })},
169 '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z')171 '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z')
170 `;172 `;
171 }173 }
172 yield* runMigrations({ toMigrationInclusive: 50 });174 yield* runMigrations({ toMigrationInclusive: 50 });
173 const rows =175 const rows =
174 yield* sql`SELECT host, repository, number FROM projection_thread_pull_requests ORDER BY repository`;176 yield* sql`SELECT host, repository, number FROM projection_thread_pull_requests ORDER BY repository`;
175 assert.deepStrictEqual(rows, [177 assert.deepStrictEqual(rows, [
176 { host: "dev.azure.com", repository: "org-a/project/_git/web", number: 7 },178 { 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 },179 { host: "dev.azure.com", repository: "org-b/project/_git/web", number: 7 },
178 ]);180 ]);
179 }),181 }),
180 );182 );
181});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";...@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";7import * 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
11layer("051_ProjectionThreadMessageContext", (it) => {11layer("051_ProjectionThreadMessageContext", (it) => {
12 it.effect("accepts context added by an earlier development migration", () =>12 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({...@@ -42,11 +42,6 @@ export const GetProjectionPendingApprovalInput = Schema.Struct({
42});42});
43export type GetProjectionPendingApprovalInput = typeof GetProjectionPendingApprovalInput.Type;43export type GetProjectionPendingApprovalInput = typeof GetProjectionPendingApprovalInput.Type;
4444
45export const DeleteProjectionPendingApprovalInput = Schema.Struct({
46 requestId: ApprovalRequestId,
47});
48export type DeleteProjectionPendingApprovalInput = typeof DeleteProjectionPendingApprovalInput.Type;
49
50/**45/**
51 * ProjectionPendingApprovalRepositoryShape - Service API for pending approvals.46 * ProjectionPendingApprovalRepositoryShape - Service API for pending approvals.
52 */47 */
...@@ -81,13 +76,6 @@ export interface ProjectionPendingApprovalRepositoryShape {...@@ -81,13 +76,6 @@ export interface ProjectionPendingApprovalRepositoryShape {
81 input: GetProjectionPendingApprovalInput,76 input: GetProjectionPendingApprovalInput,
82 ) => Effect.Effect<Option.Option<ProjectionPendingApproval>, ProjectionRepositoryError>;77 ) => 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
91 /**79 /**
92 * Delete every pending approval row for a thread.80 * Delete every pending approval row for a thread.
93 */81 */
apps/server/src/persistence/Services/ProjectionProjects.ts-22
...@@ -42,11 +42,6 @@ export const GetProjectionProjectInput = Schema.Struct({...@@ -42,11 +42,6 @@ export const GetProjectionProjectInput = Schema.Struct({
42});42});
43export type GetProjectionProjectInput = typeof GetProjectionProjectInput.Type;43export type GetProjectionProjectInput = typeof GetProjectionProjectInput.Type;
4444
45export const DeleteProjectionProjectInput = Schema.Struct({
46 projectId: ProjectId,
47});
48export type DeleteProjectionProjectInput = typeof DeleteProjectionProjectInput.Type;
49
50/**45/**
51 * ProjectionProjectRepositoryShape - Service API for projected project records.46 * ProjectionProjectRepositoryShape - Service API for projected project records.
52 */47 */
...@@ -64,23 +59,6 @@ export interface ProjectionProjectRepositoryShape {...@@ -64,23 +59,6 @@ export interface ProjectionProjectRepositoryShape {
64 readonly getById: (59 readonly getById: (
65 input: GetProjectionProjectInput,60 input: GetProjectionProjectInput,
66 ) => Effect.Effect<Option.Option<ProjectionProject>, ProjectionRepositoryError>;61 ) => 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>;
84}62}
8563
86/**64/**
apps/server/src/persistence/Services/ProjectionState.ts-7
...@@ -53,13 +53,6 @@ export interface ProjectionStateRepositoryShape {...@@ -53,13 +53,6 @@ export interface ProjectionStateRepositoryShape {
53 * List all projector cursor rows.53 * List all projector cursor rows.
54 */54 */
55 readonly listAll: () => Effect.Effect<ReadonlyArray<ProjectionState>, ProjectionRepositoryError>;55 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>;
63}56}
6457
65/**58/**
apps/server/src/persistence/Services/ProjectionThreads.ts-26
...@@ -65,16 +65,6 @@ export const GetProjectionThreadInput = Schema.Struct({...@@ -65,16 +65,6 @@ export const GetProjectionThreadInput = Schema.Struct({
65});65});
66export type GetProjectionThreadInput = typeof GetProjectionThreadInput.Type;66export 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
78/**68/**
79 * ProjectionThreadRepositoryShape - Service API for projected thread records.69 * ProjectionThreadRepositoryShape - Service API for projected thread records.
80 */70 */
...@@ -92,22 +82,6 @@ export interface ProjectionThreadRepositoryShape {...@@ -92,22 +82,6 @@ export interface ProjectionThreadRepositoryShape {
92 readonly getById: (82 readonly getById: (
93 input: GetProjectionThreadInput,83 input: GetProjectionThreadInput,
94 ) => Effect.Effect<Option.Option<ProjectionThread>, ProjectionRepositoryError>;84 ) => 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>;
111}85}
11286
113/**87/**
apps/server/src/provider/acp/XAiAcpExtension.ts-4
...@@ -110,10 +110,6 @@ interface XAiAskUserQuestionCancelledResponse {...@@ -110,10 +110,6 @@ interface XAiAskUserQuestionCancelledResponse {
110 readonly outcome: "cancelled";110 readonly outcome: "cancelled";
111}111}
112112
113export type XAiAskUserQuestionResponse =
114 | XAiAskUserQuestionAcceptedResponse
115 | XAiAskUserQuestionCancelledResponse;
116
117interface NormalizedXAiAnswer {113interface NormalizedXAiAnswer {
118 readonly questionText: string;114 readonly questionText: string;
119 readonly selectedLabels: ReadonlyArray<string>;115 readonly selectedLabels: ReadonlyArray<string>;
apps/server/src/server.test.ts+9-12
...@@ -123,10 +123,7 @@ import * as Keybindings from "./keybindings.ts";...@@ -123,10 +123,7 @@ import * as Keybindings from "./keybindings.ts";
123import * as ExternalLauncher from "./process/externalLauncher.ts";123import * as ExternalLauncher from "./process/externalLauncher.ts";
124import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";124import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
125import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";125import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
126import {126import { OrchestrationThreadSettleBlockedError } from "./orchestration/Errors.ts";
127 OrchestrationListenerCallbackError,
128 OrchestrationThreadSettleBlockedError,
129} from "./orchestration/Errors.ts";
130import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";127import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
131import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts";128import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts";
132import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts";129import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts";
...@@ -7358,8 +7355,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -7358,8 +7355,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
7358 Effect.flatMap(() =>7355 Effect.flatMap(() =>
7359 command.commandId === failedCommandId7356 command.commandId === failedCommandId
7360 ? Effect.fail(7357 ? Effect.fail(
7361 new OrchestrationListenerCallbackError({7358 new PersistenceSqlError({
7362 listener: "domain-event",7359 operation: "OrchestrationEventStore.append:query",
7363 detail: "thread creation failed",7360 detail: "thread creation failed",
7364 }),7361 }),
7365 )7362 )
...@@ -10682,8 +10679,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -10682,8 +10679,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
10682 effects.push(`dispatch:${command.type}`);10679 effects.push(`dispatch:${command.type}`);
10683 if (command.type === "thread.session.stop") {10680 if (command.type === "thread.session.stop") {
10684 return Effect.fail(10681 return Effect.fail(
10685 new OrchestrationListenerCallbackError({10682 new PersistenceSqlError({
10686 listener: "domain-event",10683 operation: "OrchestrationEventStore.append:query",
10687 detail: "simulated archive stop failure",10684 detail: "simulated archive stop failure",
10688 }),10685 }),
10689 );10686 );
...@@ -11629,8 +11626,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -11629,8 +11626,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11629 setupActivityAppendAttempt += 1;11626 setupActivityAppendAttempt += 1;
11630 if (setupActivityAppendAttempt === 2) {11627 if (setupActivityAppendAttempt === 2) {
11631 return Effect.fail(11628 return Effect.fail(
11632 new OrchestrationListenerCallbackError({11629 new PersistenceSqlError({
11633 listener: "domain-event",11630 operation: "OrchestrationEventStore.append:query",
11634 detail: "failed to append setup-script.started activity",11631 detail: "failed to append setup-script.started activity",
11635 }),11632 }),
11636 );11633 );
...@@ -12155,8 +12152,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -12155,8 +12152,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
12155 dispatchedCommands.push(command);12152 dispatchedCommands.push(command);
12156 if (command.type === "thread.delete") {12153 if (command.type === "thread.delete") {
12157 return Effect.fail(12154 return Effect.fail(
12158 new OrchestrationListenerCallbackError({12155 new PersistenceSqlError({
12159 listener: "domain-event",12156 operation: "OrchestrationEventStore.append:query",
12160 detail: "thread cleanup exploded",12157 detail: "thread cleanup exploded",
12161 }),12158 }),
12162 );12159 );
apps/server/src/textGeneration/TextGeneration.ts-2
...@@ -10,8 +10,6 @@ import * as SourceControlProviderRegistry from "../sourceControl/SourceControlPr...@@ -10,8 +10,6 @@ import * as SourceControlProviderRegistry from "../sourceControl/SourceControlPr
10import * as ThreadTitleLinks from "./ThreadTitleLinks.ts";10import * as ThreadTitleLinks from "./ThreadTitleLinks.ts";
11import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts";11import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts";
1212
13export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode";
14
15export interface CommitMessageGenerationInput {13export interface CommitMessageGenerationInput {
16 cwd: string;14 cwd: string;
17 branch: string | null;15 branch: string | null;
apps/server/src/workspace/WorkspaceSearchIndex.ts-6
...@@ -107,12 +107,6 @@ export class WorkspaceSearchIndexDestroyFailed extends Schema.TaggedError<Worksp...@@ -107,12 +107,6 @@ export class WorkspaceSearchIndexDestroyFailed extends Schema.TaggedError<Worksp
107 }107 }
108}108}
109109
110export type WorkspaceSearchIndexError =
111 | WorkspaceSearchIndexCreateFailed
112 | WorkspaceSearchIndexScanTimedOut
113 | WorkspaceSearchIndexSearchFailed
114 | WorkspaceSearchIndexRefreshFailed;
115
116export class WorkspaceSearchIndex extends Context.Service<110export class WorkspaceSearchIndex extends Context.Service<
117 WorkspaceSearchIndex,111 WorkspaceSearchIndex,
118 {112 {
apps/web/src/cloud/managedAuth.test.ts+6-3
...@@ -1,11 +1,11 @@...@@ -1,11 +1,11 @@
1import { managedRelaySessionAtom, setManagedRelaySession } from "@t3tools/client-runtime/relay";1import { managedRelaySessionAtom, setManagedRelaySession } from "@t3tools/client-runtime/relay";
2import * as Effect from "effect/Effect";
2import { afterEach, describe, expect, it, vi } from "vite-plus/test";3import { afterEach, describe, expect, it, vi } from "vite-plus/test";
34
4import { appAtomRegistry } from "../rpc/atomRegistry";5import { appAtomRegistry } from "../rpc/atomRegistry";
5import {6import {
6 activateManagedRelayAuthentication,7 activateManagedRelayAuthentication,
7 deactivateManagedRelayAuthentication,8 deactivateManagedRelayAuthentication,
8 readManagedRelayClerkToken,
9} from "./managedAuth";9} from "./managedAuth";
1010
11vi.mock("@clerk/react", () => ({11vi.mock("@clerk/react", () => ({
...@@ -32,13 +32,16 @@ describe("managed relay authentication", () => {...@@ -32,13 +32,16 @@ describe("managed relay authentication", () => {
32 it("clears all token access synchronously before account cleanup can fail", async () => {32 it("clears all token access synchronously before account cleanup can fail", async () => {
33 activateManagedRelayAuthentication("account-1", async () => "account-1-token");33 activateManagedRelayAuthentication("account-1", async () => "account-1-token");
34 expect(appAtomRegistry.get(managedRelaySessionAtom)?.accountId).toBe("account-1");34 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
37 deactivateManagedRelayAuthentication();41 deactivateManagedRelayAuthentication();
38 const cleanup = Promise.reject(new Error("Persistence removal failed.")).catch(() => undefined);42 const cleanup = Promise.reject(new Error("Persistence removal failed.")).catch(() => undefined);
3943
40 expect(appAtomRegistry.get(managedRelaySessionAtom)).toBeNull();44 expect(appAtomRegistry.get(managedRelaySessionAtom)).toBeNull();
41 expect(await readManagedRelayClerkToken()).toBeNull();
42 await cleanup;45 await cleanup;
43 });46 });
4447
apps/web/src/cloud/managedAuth.tsx-8
...@@ -14,14 +14,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry";...@@ -14,14 +14,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry";
14import { useAtomCommand } from "../state/use-atom-command";14import { useAtomCommand } from "../state/use-atom-command";
15import { resolveRelayClerkTokenOptions } from "./publicConfig";15import { 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
23export function deactivateManagedRelayAuthentication(): void {17export function deactivateManagedRelayAuthentication(): void {
24 relayTokenProvider = null;
25 setManagedRelaySession(appAtomRegistry, null);18 setManagedRelaySession(appAtomRegistry, null);
26}19}
2720
...@@ -29,7 +22,6 @@ export function activateManagedRelayAuthentication(...@@ -29,7 +22,6 @@ export function activateManagedRelayAuthentication(
29 accountId: string,22 accountId: string,
30 readClerkToken: () => Promise<string | null>,23 readClerkToken: () => Promise<string | null>,
31): void {24): void {
32 relayTokenProvider = readClerkToken;
33 setManagedRelaySession(appAtomRegistry, {25 setManagedRelaySession(appAtomRegistry, {
34 accountId,26 accountId,
35 readClerkToken,27 readClerkToken,
apps/web/src/components/ChatView.logic.test.ts-46
...@@ -32,7 +32,6 @@ import {...@@ -32,7 +32,6 @@ import {
32 usePreviewMiniPlayerStore,32 usePreviewMiniPlayerStore,
33} from "../previewMiniPlayerStore";33} from "../previewMiniPlayerStore";
34import {34import {
35 MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
36 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,35 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
37 agentControlledBrowserCloseConfirmation,36 agentControlledBrowserCloseConfirmation,
38 branchMismatchKey,37 branchMismatchKey,
...@@ -52,7 +51,6 @@ import {...@@ -52,7 +51,6 @@ import {
52 shouldRefocusComposerOnWindowFocus,51 shouldRefocusComposerOnWindowFocus,
53 isBranchMismatchDismissedForSession,52 isBranchMismatchDismissedForSession,
54 reconcileMountedTerminalThreadIds,53 reconcileMountedTerminalThreadIds,
55 reconcileRetainedMountedThreadIds,
56 recallCheckoutIsRepo,54 recallCheckoutIsRepo,
57 rememberCheckoutIsRepo,55 rememberCheckoutIsRepo,
58 resolveBackgroundDraftWorkspaceOptions,56 resolveBackgroundDraftWorkspaceOptions,
...@@ -1887,50 +1885,6 @@ describe("reconcileMountedTerminalThreadIds", () => {...@@ -1887,50 +1885,6 @@ describe("reconcileMountedTerminalThreadIds", () => {
1887 });1885 });
1888});1886});
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
1934describe("shouldWriteThreadErrorToCurrentServerThread", () => {1888describe("shouldWriteThreadErrorToCurrentServerThread", () => {
1935 it("writes errors for a shell-derived active server thread", () => {1889 it("writes errors for a shell-derived active server thread", () => {
1936 const routeThreadRef = { environmentId, threadId };1890 const routeThreadRef = { environmentId, threadId };
apps/web/src/components/ChatView.logic.ts+7-23
...@@ -60,7 +60,7 @@ import {...@@ -60,7 +60,7 @@ import {
6060
61export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";61export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
62export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;62export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
63export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3;63
64export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000;64export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000;
6565
66export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);66export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
...@@ -678,31 +678,15 @@ export function reconcileMountedTerminalThreadIds(input: {...@@ -678,31 +678,15 @@ export function reconcileMountedTerminalThreadIds(input: {
678 activeThreadId: string | null;678 activeThreadId: string | null;
679 activeThreadTerminalOpen: boolean;679 activeThreadTerminalOpen: boolean;
680 maxHiddenThreadCount?: number;680 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;
698}): string[] {681}): string[] {
699 const openThreadIdSet = new Set(input.openThreadIds);682 const openThreadIdSet = new Set(input.openThreadIds);
700 const hiddenThreadIds = input.currentThreadIds.filter(683 const hiddenThreadIds = input.currentThreadIds.filter(
701 (threadId) =>684 (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId),
702 (threadId !== input.activeThreadId || input.retainInactiveActiveThread === true) &&685 );
703 openThreadIdSet.has(threadId),686 const maxHiddenThreadCount = Math.max(
687 0,
688 input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
704 );689 );
705 const maxHiddenThreadCount = Math.max(0, input.maxHiddenThreadCount);
706 const nextThreadIds =690 const nextThreadIds =
707 hiddenThreadIds.length > maxHiddenThreadCount691 hiddenThreadIds.length > maxHiddenThreadCount
708 ? hiddenThreadIds.slice(-maxHiddenThreadCount)692 ? hiddenThreadIds.slice(-maxHiddenThreadCount)
...@@ -710,7 +694,7 @@ export function reconcileRetainedMountedThreadIds(input: {...@@ -710,7 +694,7 @@ export function reconcileRetainedMountedThreadIds(input: {
710694
711 if (695 if (
712 input.activeThreadId &&696 input.activeThreadId &&
713 input.activeThreadOpen &&697 input.activeThreadTerminalOpen &&
714 !nextThreadIds.includes(input.activeThreadId)698 !nextThreadIds.includes(input.activeThreadId)
715 ) {699 ) {
716 nextThreadIds.push(input.activeThreadId);700 nextThreadIds.push(input.activeThreadId);
apps/web/src/components/preview/PreviewPanelShell.test.ts+1-11
...@@ -1,8 +1,6 @@...@@ -1,8 +1,6 @@
1import { jsx } from "react/jsx-runtime";
2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
42
5import { getPreviewPanelMaxWidth, PreviewPanelShell } from "./PreviewPanelShell";3import { getPreviewPanelMaxWidth } from "./PreviewPanelShell";
64
7describe("getPreviewPanelMaxWidth", () => {5describe("getPreviewPanelMaxWidth", () => {
8 it("allows the panel to use 70% of an ultra-wide viewport without a pixel ceiling", () => {6 it("allows the panel to use 70% of an ultra-wide viewport without a pixel ceiling", () => {
...@@ -13,14 +11,6 @@ describe("getPreviewPanelMaxWidth", () => {...@@ -13,14 +11,6 @@ describe("getPreviewPanelMaxWidth", () => {
13 expect(getPreviewPanelMaxWidth(2_001)).toBe(1_400);11 expect(getPreviewPanelMaxWidth(2_001)).toBe(1_400);
14 });12 });
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
24 it("reserves the sibling column minimum when the flex row is known", () => {14 it("reserves the sibling column minimum when the flex row is known", () => {
25 // Fullscreen 14" MacBook: viewport 1512, sidebar ~256 → row of 1256.15 // Fullscreen 14" MacBook: viewport 1512, sidebar ~256 → row of 1256.
26 // The 70% fraction (1058) would leave the chat column only ~198px;16 // 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 @@...@@ -1,12 +1,7 @@
1import { renderToStaticMarkup } from "react-dom/server";1import { renderToStaticMarkup } from "react-dom/server";
2import { afterEach, describe, expect, it, vi } from "vite-plus/test";2import { afterEach, describe, expect, it, vi } from "vite-plus/test";
33
4import {4import { scrollToSettingsTarget, SettingsRow, SettingsUnavailableGroup } from "./settingsLayout";
5 scrollToSettingsTarget,
6 SettingsRow,
7 SettingsSearchTargetProvider,
8 SettingsUnavailableGroup,
9} from "./settingsLayout";
105
11afterEach(() => {6afterEach(() => {
12 vi.unstubAllGlobals();7 vi.unstubAllGlobals();
...@@ -27,19 +22,6 @@ describe("unavailable settings", () => {...@@ -27,19 +22,6 @@ describe("unavailable settings", () => {
27});22});
2823
29describe("settings search targets", () => {24describe("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
43 it("scrolls directly to a section header and restarts the destination pulse", () => {25 it("scrolls directly to a section header and restarts the destination pulse", () => {
44 const sectionScrollIntoView = vi.fn();26 const sectionScrollIntoView = vi.fn();
45 const headerScrollIntoView = vi.fn();27 const headerScrollIntoView = vi.fn();
apps/web/src/components/settings/settingsLayout.tsx+1-1
...@@ -57,7 +57,7 @@ const SettingsSearchTargetContext = createContext<SettingsSearchTargetContextVal...@@ -57,7 +57,7 @@ const SettingsSearchTargetContext = createContext<SettingsSearchTargetContextVal
57 onTargetHandled: noop,57 onTargetHandled: noop,
58});58});
5959
60export function SettingsSearchTargetProvider({60function SettingsSearchTargetProvider({
61 targetId,61 targetId,
62 highlightTarget = true,62 highlightTarget = true,
63 onTargetHandled = noop,63 onTargetHandled = noop,
apps/web/src/components/ui/alert-dialog.tsx-8
...@@ -9,16 +9,10 @@ import {...@@ -9,16 +9,10 @@ import {
9 DIALOG_POPUP_CLASS,9 DIALOG_POPUP_CLASS,
10} from "~/components/ui/dialog-styles";10} from "~/components/ui/dialog-styles";
1111
12const AlertDialogCreateHandle = AlertDialogPrimitive.createHandle;
13
14const AlertDialog = AlertDialogPrimitive.Root;12const AlertDialog = AlertDialogPrimitive.Root;
1513
16const AlertDialogPortal = AlertDialogPrimitive.Portal;14const AlertDialogPortal = AlertDialogPrimitive.Portal;
1715
18function AlertDialogTrigger(props: AlertDialogPrimitive.Trigger.Props) {
19 return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
20}
21
22function AlertDialogBackdrop({ className, ...props }: AlertDialogPrimitive.Backdrop.Props) {16function AlertDialogBackdrop({ className, ...props }: AlertDialogPrimitive.Backdrop.Props) {
23 return (17 return (
24 <AlertDialogPrimitive.Backdrop18 <AlertDialogPrimitive.Backdrop
...@@ -129,12 +123,10 @@ function AlertDialogClose(props: AlertDialogPrimitive.Close.Props) {...@@ -129,12 +123,10 @@ function AlertDialogClose(props: AlertDialogPrimitive.Close.Props) {
129}123}
130124
131export {125export {
132 AlertDialogCreateHandle,
133 AlertDialog,126 AlertDialog,
134 AlertDialogPortal,127 AlertDialogPortal,
135 AlertDialogBackdrop,128 AlertDialogBackdrop,
136 AlertDialogBackdrop as AlertDialogOverlay,129 AlertDialogBackdrop as AlertDialogOverlay,
137 AlertDialogTrigger,
138 AlertDialogPopup,130 AlertDialogPopup,
139 AlertDialogPopup as AlertDialogContent,131 AlertDialogPopup as AlertDialogContent,
140 AlertDialogHeader,132 AlertDialogHeader,
apps/web/src/components/ui/autocomplete.tsx-40
...@@ -136,16 +136,6 @@ function AutocompleteItem({ className, children, ...props }: AutocompletePrimiti...@@ -136,16 +136,6 @@ function AutocompleteItem({ className, children, ...props }: AutocompletePrimiti
136 );136 );
137}137}
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
149function AutocompleteGroup({ className, ...props }: AutocompletePrimitive.Group.Props) {139function AutocompleteGroup({ className, ...props }: AutocompletePrimitive.Group.Props) {
150 return (140 return (
151 <AutocompletePrimitive.Group141 <AutocompletePrimitive.Group
...@@ -179,16 +169,6 @@ function AutocompleteEmpty({ className, ...props }: AutocompletePrimitive.Empty....@@ -179,16 +169,6 @@ function AutocompleteEmpty({ className, ...props }: AutocompletePrimitive.Empty.
179 );169 );
180}170}
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
192function AutocompleteList({ className, ...props }: AutocompletePrimitive.List.Props) {172function AutocompleteList({ className, ...props }: AutocompletePrimitive.List.Props) {
193 return (173 return (
194 <ScrollArea scrollbarGutter scrollFade>174 <ScrollArea scrollbarGutter scrollFade>
...@@ -216,19 +196,6 @@ function AutocompleteClear({ className, ...props }: AutocompletePrimitive.Clear....@@ -216,19 +196,6 @@ function AutocompleteClear({ className, ...props }: AutocompletePrimitive.Clear.
216 );196 );
217}197}
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
232function AutocompleteCollection({ ...props }: AutocompletePrimitive.Collection.Props) {199function AutocompleteCollection({ ...props }: AutocompletePrimitive.Collection.Props) {
233 return <AutocompletePrimitive.Collection data-slot="autocomplete-collection" {...props} />;200 return <AutocompletePrimitive.Collection data-slot="autocomplete-collection" {...props} />;
234}201}
...@@ -249,23 +216,16 @@ function AutocompleteTrigger({...@@ -249,23 +216,16 @@ function AutocompleteTrigger({
249 );216 );
250}217}
251218
252const useAutocompleteFilter = AutocompletePrimitive.useFilter;
253
254export {219export {
255 Autocomplete,220 Autocomplete,
256 AutocompleteInput,221 AutocompleteInput,
257 AutocompleteTrigger,222 AutocompleteTrigger,
258 AutocompletePopup,223 AutocompletePopup,
259 AutocompleteItem,224 AutocompleteItem,
260 AutocompleteSeparator,
261 AutocompleteGroup,225 AutocompleteGroup,
262 AutocompleteGroupLabel,226 AutocompleteGroupLabel,
263 AutocompleteEmpty,227 AutocompleteEmpty,
264 AutocompleteValue,
265 AutocompleteList,228 AutocompleteList,
266 AutocompleteClear,229 AutocompleteClear,
267 AutocompleteStatus,
268 AutocompleteRow,
269 AutocompleteCollection,230 AutocompleteCollection,
270 useAutocompleteFilter,
271};231};
apps/web/src/components/ui/combobox.tsx-146
...@@ -28,31 +28,6 @@ function Combobox<Value, Multiple extends boolean | undefined = false>(...@@ -28,31 +28,6 @@ function Combobox<Value, Multiple extends boolean | undefined = false>(
28 );28 );
29}29}
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
56function ComboboxInput({31function ComboboxInput({
57 className,32 className,
58 inputClassName,33 inputClassName,
...@@ -240,36 +215,6 @@ function ComboboxItem({...@@ -240,36 +215,6 @@ function ComboboxItem({
240 );215 );
241}216}
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
273function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {218function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
274 return (219 return (
275 <ComboboxPrimitive.Empty220 <ComboboxPrimitive.Empty
...@@ -283,14 +228,6 @@ function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {...@@ -283,14 +228,6 @@ function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
283 );228 );
284}229}
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
294function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {231function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
295 return (232 return (
296 <ScrollArea scrollbarGutter scrollFade>233 <ScrollArea scrollbarGutter scrollFade>
...@@ -334,102 +271,19 @@ function ComboboxStatus({ className, ...props }: ComboboxPrimitive.Status.Props)...@@ -334,102 +271,19 @@ function ComboboxStatus({ className, ...props }: ComboboxPrimitive.Status.Props)
334 );271 );
335}272}
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
411const useComboboxFilter = ComboboxPrimitive.useFilter;274const useComboboxFilter = ComboboxPrimitive.useFilter;
412275
413export {276export {
414 Combobox,277 Combobox,
415 ComboboxChipsInput,
416 ComboboxInput,278 ComboboxInput,
417 ComboboxSearchInput,279 ComboboxSearchInput,
418 ComboboxTrigger,280 ComboboxTrigger,
419 ComboboxPopup,281 ComboboxPopup,
420 ComboboxItem,282 ComboboxItem,
421 ComboboxSeparator,
422 ComboboxGroup,
423 ComboboxGroupLabel,
424 ComboboxEmpty,283 ComboboxEmpty,
425 ComboboxValue,
426 ComboboxList,284 ComboboxList,
427 ComboboxListVirtualized,285 ComboboxListVirtualized,
428 ComboboxClear,286 ComboboxClear,
429 ComboboxStatus,287 ComboboxStatus,
430 ComboboxRow,
431 ComboboxCollection,
432 ComboboxChips,
433 ComboboxChip,
434 useComboboxFilter,288 useComboboxFilter,
435};289};
apps/web/src/components/ui/command.tsx-30
...@@ -7,13 +7,11 @@ import { cn } from "~/lib/utils";...@@ -7,13 +7,11 @@ import { cn } from "~/lib/utils";
7import {7import {
8 Autocomplete,8 Autocomplete,
9 AutocompleteCollection,9 AutocompleteCollection,
10 AutocompleteEmpty,
11 AutocompleteGroup,10 AutocompleteGroup,
12 AutocompleteGroupLabel,11 AutocompleteGroupLabel,
13 AutocompleteInput,12 AutocompleteInput,
14 AutocompleteItem,13 AutocompleteItem,
15 AutocompleteList,14 AutocompleteList,
16 AutocompleteSeparator,
17} from "~/components/ui/autocomplete";15} from "~/components/ui/autocomplete";
18import { DIALOG_BACKDROP_CLASS, DIALOG_POPUP_CLASS } from "~/components/ui/dialog-styles";16import { DIALOG_BACKDROP_CLASS, DIALOG_POPUP_CLASS } from "~/components/ui/dialog-styles";
19import { Button } from "~/components/ui/button";17import { Button } from "~/components/ui/button";
...@@ -22,8 +20,6 @@ const CommandDialog = CommandDialogPrimitive.Root;...@@ -22,8 +20,6 @@ const CommandDialog = CommandDialogPrimitive.Root;
2220
23const CommandDialogPortal = CommandDialogPrimitive.Portal;21const CommandDialogPortal = CommandDialogPrimitive.Portal;
2422
25const CommandCreateHandle = CommandDialogPrimitive.createHandle;
26
27function CommandDialogTrigger(props: CommandDialogPrimitive.Trigger.Props) {23function CommandDialogTrigger(props: CommandDialogPrimitive.Trigger.Props) {
28 return <CommandDialogPrimitive.Trigger data-slot="command-dialog-trigger" {...props} />;24 return <CommandDialogPrimitive.Trigger data-slot="command-dialog-trigger" {...props} />;
29}25}
...@@ -135,16 +131,6 @@ function CommandList({ className, ...props }: React.ComponentProps<typeof Autoco...@@ -135,16 +131,6 @@ function CommandList({ className, ...props }: React.ComponentProps<typeof Autoco
135 );131 );
136}132}
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
148function CommandPanel({ className, ...props }: React.ComponentProps<"div">) {134function CommandPanel({ className, ...props }: React.ComponentProps<"div">) {
149 return (135 return (
150 <div136 <div
...@@ -187,19 +173,6 @@ function CommandItem({ className, ...props }: React.ComponentProps<typeof Autoco...@@ -187,19 +173,6 @@ function CommandItem({ className, ...props }: React.ComponentProps<typeof Autoco
187 );173 );
188}174}
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
203function CommandShortcut({ className, ...props }: React.ComponentProps<"kbd">) {176function CommandShortcut({ className, ...props }: React.ComponentProps<"kbd">) {
204 return (177 return (
205 <kbd178 <kbd
...@@ -241,13 +214,11 @@ function CommandFooterAction({...@@ -241,13 +214,11 @@ function CommandFooterAction({
241}214}
242215
243export {216export {
244 CommandCreateHandle,
245 Command,217 Command,
246 CommandCollection,218 CommandCollection,
247 CommandDialog,219 CommandDialog,
248 CommandDialogPopup,220 CommandDialogPopup,
249 CommandDialogTrigger,221 CommandDialogTrigger,
250 CommandEmpty,
251 CommandFooter,222 CommandFooter,
252 CommandFooterAction,223 CommandFooterAction,
253 CommandGroup,224 CommandGroup,
...@@ -256,6 +227,5 @@ export {...@@ -256,6 +227,5 @@ export {
256 CommandItem,227 CommandItem,
257 CommandList,228 CommandList,
258 CommandPanel,229 CommandPanel,
259 CommandSeparator,
260 CommandShortcut,230 CommandShortcut,
261};231};
apps/web/src/components/ui/dialog.tsx-3
...@@ -13,8 +13,6 @@ import {...@@ -13,8 +13,6 @@ import {
13} from "~/components/ui/dialog-styles";13} from "~/components/ui/dialog-styles";
14import { ScrollArea } from "~/components/ui/scroll-area";14import { ScrollArea } from "~/components/ui/scroll-area";
1515
16const DialogCreateHandle = DialogPrimitive.createHandle;
17
18const Dialog = DialogPrimitive.Root;16const Dialog = DialogPrimitive.Root;
1917
20const DialogPortal = DialogPrimitive.Portal;18const DialogPortal = DialogPrimitive.Portal;
...@@ -184,7 +182,6 @@ function DialogPanel({...@@ -184,7 +182,6 @@ function DialogPanel({
184}182}
185183
186export {184export {
187 DialogCreateHandle,
188 Dialog,185 Dialog,
189 DialogTrigger,186 DialogTrigger,
190 DialogPortal,187 DialogPortal,
apps/web/src/components/ui/group.tsx-20
...@@ -1,7 +1,4 @@...@@ -1,7 +1,4 @@
1"use client";1"use client";
2
3import { mergeProps } from "@base-ui/react/merge-props";
4import { useRender } from "@base-ui/react/use-render";
5import { cva, type VariantProps } from "class-variance-authority";2import { cva, type VariantProps } from "class-variance-authority";
6import type * as React from "react";3import type * as React from "react";
74
...@@ -48,21 +45,6 @@ function Group({...@@ -48,21 +45,6 @@ function Group({
48 );45 );
49}46}
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
66function GroupSeparator({48function GroupSeparator({
67 className,49 className,
68 orientation = "vertical",50 orientation = "vertical",
...@@ -85,8 +67,6 @@ function GroupSeparator({...@@ -85,8 +67,6 @@ function GroupSeparator({
85export {67export {
86 Group,68 Group,
87 Group as ButtonGroup,69 Group as ButtonGroup,
88 GroupText,
89 GroupText as ButtonGroupText,
90 GroupSeparator,70 GroupSeparator,
91 GroupSeparator as ButtonGroupSeparator,71 GroupSeparator as ButtonGroupSeparator,
92 groupVariants,72 groupVariants,
apps/web/src/components/ui/input-group.tsx+1-18
...@@ -5,7 +5,6 @@ import type * as React from "react";...@@ -5,7 +5,6 @@ import type * as React from "react";
55
6import { cn } from "~/lib/utils";6import { cn } from "~/lib/utils";
7import { Input, type InputProps } from "~/components/ui/input";7import { Input, type InputProps } from "~/components/ui/input";
8import { Textarea, type TextareaProps } from "~/components/ui/textarea";
98
10const inputGroupVariants = cva(9const inputGroupVariants = cva(
11 "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)]",10 "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({...@@ -90,24 +89,8 @@ function InputGroupAddon({
90 );89 );
91}90}
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
105function InputGroupInput({ className, ...props }: InputProps) {92function InputGroupInput({ className, ...props }: InputProps) {
106 return <Input className={className} unstyled {...props} />;93 return <Input className={className} unstyled {...props} />;
107}94}
10895
109function InputGroupTextarea({ className, ...props }: TextareaProps) {96export { InputGroup, InputGroupAddon, InputGroupInput };
110 return <Textarea className={className} unstyled {...props} />;
111}
112
113export { InputGroup, InputGroupAddon, InputGroupText, InputGroupInput, InputGroupTextarea };
apps/web/src/components/ui/menu.tsx-8
...@@ -7,12 +7,8 @@ import type * as React from "react";...@@ -7,12 +7,8 @@ import type * as React from "react";
7import { cn } from "~/lib/utils";7import { cn } from "~/lib/utils";
8import { buttonVariants } from "./button";8import { buttonVariants } from "./button";
99
10const MenuCreateHandle = MenuPrimitive.createHandle;
11
12const Menu = MenuPrimitive.Root;10const Menu = MenuPrimitive.Root;
1311
14const MenuPortal = MenuPrimitive.Portal;
15
16function MenuTrigger({ className, children, ...props }: MenuPrimitive.Trigger.Props) {12function MenuTrigger({ className, children, ...props }: MenuPrimitive.Trigger.Props) {
17 return (13 return (
18 <MenuPrimitive.Trigger className={className} data-slot="menu-trigger" {...props}>14 <MenuPrimitive.Trigger className={className} data-slot="menu-trigger" {...props}>
...@@ -304,12 +300,8 @@ function MenuSubPopup({...@@ -304,12 +300,8 @@ function MenuSubPopup({
304}300}
305301
306export {302export {
307 MenuCreateHandle,
308 MenuCreateHandle as DropdownMenuCreateHandle,
309 Menu,303 Menu,
310 Menu as DropdownMenu,304 Menu as DropdownMenu,
311 MenuPortal,
312 MenuPortal as DropdownMenuPortal,
313 MenuTrigger,305 MenuTrigger,
314 MenuTrigger as DropdownMenuTrigger,306 MenuTrigger as DropdownMenuTrigger,
315 MenuPopup,307 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...@@ -4,13 +4,6 @@ import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field
4import { MinusIcon, PlusIcon } from "lucide-react";4import { MinusIcon, PlusIcon } from "lucide-react";
5import * as React from "react";5import * as React from "react";
6import { cn } from "~/lib/utils";6import { 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
15export function NumberField({8export function NumberField({
16 id,9 id,
...@@ -22,18 +15,14 @@ export function NumberField({...@@ -22,18 +15,14 @@ export function NumberField({
22}): React.ReactElement {15}): React.ReactElement {
23 const generatedId = React.useId();16 const generatedId = React.useId();
24 const fieldId = id ?? generatedId;17 const fieldId = id ?? generatedId;
25 const contextValue = React.useMemo(() => ({ fieldId }), [fieldId]);
26
27 return (18 return (
28 <NumberFieldContext value={contextValue}>19 <NumberFieldPrimitive.Root
29 <NumberFieldPrimitive.Root20 className={cn("flex w-full flex-col items-start gap-2", className)}
30 className={cn("flex w-full flex-col items-start gap-2", className)}21 data-size={size}
31 data-size={size}22 data-slot="number-field"
32 data-slot="number-field"23 id={fieldId}
33 id={fieldId}24 {...props}
34 {...props}25 />
35 />
36 </NumberFieldContext>
37 );26 );
38}27}
3928
...@@ -105,52 +94,4 @@ export function NumberFieldInput({...@@ -105,52 +94,4 @@ export function NumberFieldInput({
105 );94 );
106}95}
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
156export { NumberFieldPrimitive };97export { NumberFieldPrimitive };
apps/web/src/components/ui/popover.tsx-11
...@@ -91,16 +91,6 @@ function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {...@@ -91,16 +91,6 @@ function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
91 );91 );
92}92}
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
104export {94export {
105 PopoverCreateHandle,95 PopoverCreateHandle,
106 Popover,96 Popover,
...@@ -108,6 +98,5 @@ export {...@@ -108,6 +98,5 @@ export {
108 PopoverPopup,98 PopoverPopup,
109 PopoverPopup as PopoverContent,99 PopoverPopup as PopoverContent,
110 PopoverTitle,100 PopoverTitle,
111 PopoverDescription,
112 PopoverClose,101 PopoverClose,
113};102};
apps/web/src/components/ui/select.tsx+1-51
...@@ -1,10 +1,7 @@...@@ -1,10 +1,7 @@
1"use client";1"use client";
2
3import { mergeProps } from "@base-ui/react/merge-props";
4import { Select as SelectPrimitive } from "@base-ui/react/select";2import { Select as SelectPrimitive } from "@base-ui/react/select";
5import { useRender } from "@base-ui/react/use-render";
6import { cva, type VariantProps } from "class-variance-authority";3import { cva, type VariantProps } from "class-variance-authority";
7import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from "lucide-react";4import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
8import type * as React from "react";5import type * as React from "react";
96
10import { cn } from "~/lib/utils";7import { cn } from "~/lib/utils";
...@@ -37,41 +34,6 @@ const selectTriggerVariants = cva(...@@ -37,41 +34,6 @@ const selectTriggerVariants = cva(
37 },34 },
38);35);
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
75function SelectTrigger({37function SelectTrigger({
76 className,38 className,
77 size = "default",39 size = "default",
...@@ -203,16 +165,6 @@ function SelectItem({...@@ -203,16 +165,6 @@ function SelectItem({
203 );165 );
204}166}
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
216function SelectGroup(props: SelectPrimitive.Group.Props) {168function SelectGroup(props: SelectPrimitive.Group.Props) {
217 return <SelectPrimitive.Group data-slot="select-group" {...props} />;169 return <SelectPrimitive.Group data-slot="select-group" {...props} />;
218}170}
...@@ -230,13 +182,11 @@ function SelectGroupLabel(props: SelectPrimitive.GroupLabel.Props) {...@@ -230,13 +182,11 @@ function SelectGroupLabel(props: SelectPrimitive.GroupLabel.Props) {
230export {182export {
231 Select,183 Select,
232 SelectTrigger,184 SelectTrigger,
233 SelectButton,
234 selectTriggerVariants,185 selectTriggerVariants,
235 SelectValue,186 SelectValue,
236 SelectPopup,187 SelectPopup,
237 SelectPopup as SelectContent,188 SelectPopup as SelectContent,
238 SelectItem,189 SelectItem,
239 SelectSeparator,
240 SelectGroup,190 SelectGroup,
241 SelectGroupLabel,191 SelectGroupLabel,
242};192};
apps/web/src/components/ui/sheet.tsx-54
...@@ -5,20 +5,11 @@ import { XIcon } from "lucide-react";...@@ -5,20 +5,11 @@ import { XIcon } from "lucide-react";
5import type { CSSProperties } from "react";5import type { CSSProperties } from "react";
6import { cn } from "~/lib/utils";6import { cn } from "~/lib/utils";
7import { Button } from "~/components/ui/button";7import { Button } from "~/components/ui/button";
8import { ScrollArea } from "~/components/ui/scroll-area";
98
10const Sheet = SheetPrimitive.Root;9const Sheet = SheetPrimitive.Root;
1110
12const SheetPortal = SheetPrimitive.Portal;11const 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
22function SheetBackdrop({ className, ...props }: SheetPrimitive.Backdrop.Props) {13function SheetBackdrop({ className, ...props }: SheetPrimitive.Backdrop.Props) {
23 return (14 return (
24 <SheetPrimitive.Backdrop15 <SheetPrimitive.Backdrop
...@@ -146,28 +137,6 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {...@@ -146,28 +137,6 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
146 );137 );
147}138}
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
171function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {140function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
172 return (141 return (
173 <SheetPrimitive.Title142 <SheetPrimitive.Title
...@@ -188,37 +157,14 @@ function SheetDescription({ className, ...props }: SheetPrimitive.Description.Pr...@@ -188,37 +157,14 @@ function SheetDescription({ className, ...props }: SheetPrimitive.Description.Pr
188 );157 );
189}158}
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
210export {160export {
211 Sheet,161 Sheet,
212 SheetTrigger,
213 SheetPortal,162 SheetPortal,
214 SheetClose,
215 SheetBackdrop,163 SheetBackdrop,
216 SheetBackdrop as SheetOverlay,164 SheetBackdrop as SheetOverlay,
217 SheetPopup,165 SheetPopup,
218 SheetPopup as SheetContent,166 SheetPopup as SheetContent,
219 SheetHeader,167 SheetHeader,
220 SheetFooter,
221 SheetTitle,168 SheetTitle,
222 SheetDescription,169 SheetDescription,
223 SheetPanel,
224};170};
apps/web/src/components/ui/sidebar.test.tsx+1-85
...@@ -1,23 +1,8 @@...@@ -1,23 +1,8 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
32
4import {
5 SidebarMenuButton,
6 SidebarMenuSubButton,
7 SidebarProvider,
8 SidebarTrigger,
9} from "./sidebar";
10import { resolveSidebarState } from "./sidebarState";3import { resolveSidebarState } from "./sidebarState";
114
12function renderSidebarButton(className?: string) {5describe("responsive sidebar state", () => {
13 return renderToStaticMarkup(
14 <SidebarProvider>
15 <SidebarMenuButton className={className}>Projects</SidebarMenuButton>
16 </SidebarProvider>,
17 );
18}
19
20describe("sidebar interactive cursors", () => {
21 it("uses mobile sheet visibility for the shared responsive state", () => {6 it("uses mobile sheet visibility for the shared responsive state", () => {
22 expect(resolveSidebarState({ isMobile: true, open: true, openMobile: false })).toBe(7 expect(resolveSidebarState({ isMobile: true, open: true, openMobile: false })).toBe(
23 "collapsed",8 "collapsed",
...@@ -27,73 +12,4 @@ describe("sidebar interactive cursors", () => {...@@ -27,73 +12,4 @@ describe("sidebar interactive cursors", () => {
27 "expanded",12 "expanded",
28 );13 );
29 });14 });
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 });
99});15});
apps/web/src/components/ui/sidebar.tsx-133
...@@ -5,9 +5,7 @@ import { PanelLeftCloseIcon, PanelLeftIcon } from "lucide-react";...@@ -5,9 +5,7 @@ import { PanelLeftCloseIcon, PanelLeftIcon } from "lucide-react";
5import * as React from "react";5import * as React from "react";
6import { cn } from "~/lib/utils";6import { cn } from "~/lib/utils";
7import { Button } from "~/components/ui/button";7import { Button } from "~/components/ui/button";
8import { Input } from "~/components/ui/input";
9import { ScrollArea } from "~/components/ui/scroll-area";8import { ScrollArea } from "~/components/ui/scroll-area";
10import { Separator } from "~/components/ui/separator";
11import {9import {
12 Sheet,10 Sheet,
13 SheetDescription,11 SheetDescription,
...@@ -15,7 +13,6 @@ import {...@@ -15,7 +13,6 @@ import {
15 SheetPopup,13 SheetPopup,
16 SheetTitle,14 SheetTitle,
17} from "~/components/ui/sheet";15} from "~/components/ui/sheet";
18import { Skeleton } from "~/components/ui/skeleton";
19import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";16import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
20import { useResizeDrag } from "~/hooks/useResizeDrag";17import { useResizeDrag } from "~/hooks/useResizeDrag";
21import { useIsMobile } from "~/hooks/useMediaQuery";18import { useIsMobile } from "~/hooks/useMediaQuery";
...@@ -544,17 +541,6 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {...@@ -544,17 +541,6 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
544 );541 );
545}542}
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
558function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {544function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
559 return (545 return (
560 <div546 <div
...@@ -577,17 +563,6 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {...@@ -577,17 +563,6 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
577 );563 );
578}564}
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
591function SidebarContent({566function SidebarContent({
592 className,567 className,
593 fixedHeader,568 fixedHeader,
...@@ -635,55 +610,6 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {...@@ -635,55 +610,6 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
635 );610 );
636}611}
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
687function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {613function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
688 return (614 return (
689 <ul615 <ul
...@@ -782,58 +708,6 @@ function SidebarMenuButton({...@@ -782,58 +708,6 @@ function SidebarMenuButton({
782 );708 );
783}709}
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
837function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {711function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
838 return (712 return (
839 <ul713 <ul
...@@ -897,23 +771,16 @@ export {...@@ -897,23 +771,16 @@ export {
897 SidebarContent,771 SidebarContent,
898 SidebarFooter,772 SidebarFooter,
899 SidebarGroup,773 SidebarGroup,
900 SidebarGroupAction,
901 SidebarGroupContent,
902 SidebarGroupLabel,
903 SidebarHeader,774 SidebarHeader,
904 SidebarInput,
905 SidebarInset,775 SidebarInset,
906 SidebarMenu,776 SidebarMenu,
907 SidebarMenuBadge,
908 SidebarMenuButton,777 SidebarMenuButton,
909 SidebarMenuItem,778 SidebarMenuItem,
910 SidebarMenuSkeleton,
911 SidebarMenuSub,779 SidebarMenuSub,
912 SidebarMenuSubButton,780 SidebarMenuSubButton,
913 SidebarMenuSubItem,781 SidebarMenuSubItem,
914 SidebarProvider,782 SidebarProvider,
915 SidebarRail,783 SidebarRail,
916 SidebarSeparator,
917 SidebarTrigger,784 SidebarTrigger,
918 useSidebar,785 useSidebar,
919 useSidebarVisibility,786 useSidebarVisibility,
apps/web/src/components/ui/table.tsx+1-21
...@@ -28,16 +28,6 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {...@@ -28,16 +28,6 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
28 );28 );
29}29}
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
41function TableRow({ className, ...props }: React.ComponentProps<"tr">) {31function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
42 return (32 return (
43 <tr33 <tr
...@@ -74,14 +64,4 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {...@@ -74,14 +64,4 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
74 );64 );
75}65}
7666
77function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {67export { Table, TableHeader, TableBody, TableHead, TableRow, TableCell };
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 };
apps/web/src/components/ui/toggle-group.tsx+1-21
...@@ -6,7 +6,6 @@ import type { VariantProps } from "class-variance-authority";...@@ -6,7 +6,6 @@ import type { VariantProps } from "class-variance-authority";
6import * as React from "react";6import * as React from "react";
77
8import { cn } from "~/lib/utils";8import { cn } from "~/lib/utils";
9import { Separator } from "~/components/ui/separator";
10import { Toggle as ToggleComponent, type toggleVariants } from "~/components/ui/toggle";9import { Toggle as ToggleComponent, type toggleVariants } from "~/components/ui/toggle";
1110
12const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({11const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
...@@ -76,23 +75,4 @@ function Toggle({...@@ -76,23 +75,4 @@ function Toggle({
76 );75 );
77}76}
7877
79function ToggleGroupSeparator({78export { ToggleGroup, Toggle, Toggle as ToggleGroupItem };
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 };
apps/web/src/components/ui/tooltip.tsx+1-3
...@@ -2,8 +2,6 @@ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";...@@ -2,8 +2,6 @@ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
22
3import { cn } from "~/lib/utils";3import { cn } from "~/lib/utils";
44
5const TooltipCreateHandle = TooltipPrimitive.createHandle;
6
7const TooltipProvider = TooltipPrimitive.Provider;5const TooltipProvider = TooltipPrimitive.Provider;
86
9const Tooltip = TooltipPrimitive.Root;7const Tooltip = TooltipPrimitive.Root;
...@@ -61,4 +59,4 @@ function TooltipPopup({...@@ -61,4 +59,4 @@ function TooltipPopup({
61 );59 );
62}60}
6361
64export { TooltipCreateHandle, TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup };62export { TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup };
apps/web/src/providerInstances.test.ts-39
...@@ -9,7 +9,6 @@ import {...@@ -9,7 +9,6 @@ import {
9 isProviderInstancePickerVisible,9 isProviderInstancePickerVisible,
10 resolveDefaultProviderModelSelection,10 resolveDefaultProviderModelSelection,
11 resolveSelectableProviderInstance,11 resolveSelectableProviderInstance,
12 resolveProviderDriverKindForInstanceSelection,
13} from "./providerInstances";12} from "./providerInstances";
1413
15function provider(input: {14function provider(input: {
...@@ -362,44 +361,6 @@ describe("resolveSelectableProviderInstance", () => {...@@ -362,44 +361,6 @@ describe("resolveSelectableProviderInstance", () => {
362 });361 });
363});362});
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
403describe("getDefaultProviderInstanceModel", () => {364describe("getDefaultProviderInstanceModel", () => {
404 it("uses the instance's own models, not the default instance of the kind", () => {365 it("uses the instance's own models, not the default instance of the kind", () => {
405 const providers = [366 const providers = [
apps/web/src/providerInstances.ts-18
...@@ -298,21 +298,3 @@ export function resolveDefaultProviderModelSelection(...@@ -298,21 +298,3 @@ export function resolveDefaultProviderModelSelection(
298 const model = getDefaultProviderInstanceModel(providers, instanceId);298 const model = getDefaultProviderInstanceModel(providers, instanceId);
299 return model ? { instanceId, model } : null;299 return model ? { instanceId, model } : null;
300}300}
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 {...@@ -5,7 +5,6 @@ import type {
5 ChatUnknownAttachment as ContractChatUnknownAttachment,5 ChatUnknownAttachment as ContractChatUnknownAttachment,
6 OrchestrationCheckpointFile,6 OrchestrationCheckpointFile,
7 OrchestrationCheckpointSummary,7 OrchestrationCheckpointSummary,
8 OrchestrationLatestTurn,
9 OrchestrationMessage,8 OrchestrationMessage,
10 OrchestrationProposedPlan,9 OrchestrationProposedPlan,
11 OrchestrationSession,10 OrchestrationSession,
...@@ -86,9 +85,5 @@ export type Project = EnvironmentProject;...@@ -86,9 +85,5 @@ export type Project = EnvironmentProject;
86export type Thread = EnvironmentThread;85export type Thread = EnvironmentThread;
87export type ThreadShell = EnvironmentThreadShell;86export type ThreadShell = EnvironmentThreadShell;
8887
89export interface ThreadTurnState {
90 latestTurn: OrchestrationLatestTurn | null;
91}
92
93export type SidebarThreadSummary = EnvironmentThreadShell;88export type SidebarThreadSummary = EnvironmentThreadShell;
94export type ThreadSession = OrchestrationSession;89export 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(...@@ -84,7 +84,6 @@ function makeEnvironmentLinks(
84): EnvironmentLinks.EnvironmentLinks["Service"] {84): EnvironmentLinks.EnvironmentLinks["Service"] {
85 return {85 return {
86 upsert: () => Effect.void,86 upsert: () => Effect.void,
87 listUsersForEnvironment: () => Effect.succeed(["dev:julius"]),
88 listDeliveryUsersForEnvironment: () =>87 listDeliveryUsersForEnvironment: () =>
89 Effect.succeed([88 Effect.succeed([
90 {89 {
...@@ -93,7 +92,6 @@ function makeEnvironmentLinks(...@@ -93,7 +92,6 @@ function makeEnvironmentLinks(
93 liveActivitiesEnabled: true,92 liveActivitiesEnabled: true,
94 },93 },
95 ]),94 ]),
96 listPublicKeysForEnvironment: () => Effect.succeed([]),
97 listForUser: () => Effect.succeed([]),95 listForUser: () => Effect.succeed([]),
98 getForUser: () => Effect.succeed(null),96 getForUser: () => Effect.succeed(null),
99 revokeForUser: () => Effect.succeed(false),97 revokeForUser: () => Effect.succeed(false),
infra/relay/src/agentActivity/ApnsClient.ts+2-7
...@@ -9,7 +9,7 @@ import * as HttpClient from "effect/unstable/http/HttpClient";...@@ -9,7 +9,7 @@ import * as HttpClient from "effect/unstable/http/HttpClient";
9import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";9import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
10import { ApnsEnvironment as ApnsEnvironmentSchema, type ApnsCredentials } from "../Config.ts";10import { ApnsEnvironment as ApnsEnvironmentSchema, type ApnsCredentials } from "../Config.ts";
11import type { ApnsLiveActivityAlert, ApnsNotificationPayload } from "./apnsDeliveryJobs.ts";11import type { ApnsLiveActivityAlert, ApnsNotificationPayload } from "./apnsDeliveryJobs.ts";
12import { ApnsJwtEncodingError, ApnsJwtSigningError } from "./apnsJwt.ts";12import type { ApnsJwtEncodingError, ApnsJwtSigningError } from "./apnsJwt.ts";
13import * as ApnsProviderTokens from "./ApnsProviderTokens.ts";13import * as ApnsProviderTokens from "./ApnsProviderTokens.ts";
1414
15export { ApnsJwtEncodingError, ApnsJwtSigningError } from "./apnsJwt.ts";15export { ApnsJwtEncodingError, ApnsJwtSigningError } from "./apnsJwt.ts";
...@@ -71,12 +71,7 @@ export class ApnsHttpRequestError extends Schema.TaggedError<ApnsHttpRequestErro...@@ -71,12 +71,7 @@ export class ApnsHttpRequestError extends Schema.TaggedError<ApnsHttpRequestErro
71 }71 }
72}72}
7373
74export const ApnsError = Schema.Union([74export type ApnsError = ApnsJwtEncodingError | ApnsJwtSigningError | ApnsHttpRequestError;
75 ApnsJwtEncodingError,
76 ApnsJwtSigningError,
77 ApnsHttpRequestError,
78]);
79export type ApnsError = typeof ApnsError.Type;
8075
81const decodeApnsErrorResponseJson = Schema.decodeUnknownOption(76const decodeApnsErrorResponseJson = Schema.decodeUnknownOption(
82 Schema.fromJsonString(77 Schema.fromJsonString(
infra/relay/src/agentActivity/FcmDeliveries.test.ts-2
...@@ -162,7 +162,6 @@ function harness() {...@@ -162,7 +162,6 @@ function harness() {
162 }),162 }),
163 Layer.succeed(EnvironmentLinks, {163 Layer.succeed(EnvironmentLinks, {
164 upsert: () => Effect.void,164 upsert: () => Effect.void,
165 listUsersForEnvironment: () => Effect.succeed(["user"]),
166 listDeliveryUsersForEnvironment: (input) =>165 listDeliveryUsersForEnvironment: (input) =>
167 Effect.sync(() =>166 Effect.sync(() =>
168 current.linked && !current.revokedEnvironments.includes(input.environmentId)167 current.linked && !current.revokedEnvironments.includes(input.environmentId)
...@@ -177,7 +176,6 @@ function harness() {...@@ -177,7 +176,6 @@ function harness() {
177 ]176 ]
178 : [],177 : [],
179 ),178 ),
180 listPublicKeysForEnvironment: () => Effect.succeed([]),
181 listForUser: () => Effect.succeed([]),179 listForUser: () => Effect.succeed([]),
182 revokeForUser: () => Effect.succeed(false),180 revokeForUser: () => Effect.succeed(false),
183 getForUser: (input) =>181 getForUser: (input) =>
infra/relay/src/agentActivity/MobileRegistrations.test.ts-2
...@@ -106,7 +106,6 @@ function makeEnvironmentLinks(...@@ -106,7 +106,6 @@ function makeEnvironmentLinks(
106): EnvironmentLinks.EnvironmentLinks["Service"] {106): EnvironmentLinks.EnvironmentLinks["Service"] {
107 return {107 return {
108 upsert: () => Effect.void,108 upsert: () => Effect.void,
109 listUsersForEnvironment: () => Effect.succeed(["dev:julius"]),
110 listDeliveryUsersForEnvironment: () =>109 listDeliveryUsersForEnvironment: () =>
111 Effect.succeed([110 Effect.succeed([
112 {111 {
...@@ -115,7 +114,6 @@ function makeEnvironmentLinks(...@@ -115,7 +114,6 @@ function makeEnvironmentLinks(
115 liveActivitiesEnabled: true,114 liveActivitiesEnabled: true,
116 },115 },
117 ]),116 ]),
118 listPublicKeysForEnvironment: () => Effect.succeed([]),
119 listForUser: () => Effect.succeed([]),117 listForUser: () => Effect.succeed([]),
120 getForUser: () => Effect.succeed(null),118 getForUser: () => Effect.succeed(null),
121 revokeForUser: () => Effect.succeed(false),119 revokeForUser: () => Effect.succeed(false),
infra/relay/src/environments/EnvironmentConnector.test.ts-2
...@@ -209,9 +209,7 @@ function makeLinks(...@@ -209,9 +209,7 @@ function makeLinks(
209): EnvironmentLinks.EnvironmentLinks["Service"] {209): EnvironmentLinks.EnvironmentLinks["Service"] {
210 return {210 return {
211 upsert: () => Effect.void,211 upsert: () => Effect.void,
212 listUsersForEnvironment: () => Effect.succeed([]),
213 listDeliveryUsersForEnvironment: () => Effect.succeed([]),212 listDeliveryUsersForEnvironment: () => Effect.succeed([]),
214 listPublicKeysForEnvironment: () => Effect.succeed([environmentKeyPair.publicKey]),
215 listForUser: () => Effect.succeed([]),213 listForUser: () => Effect.succeed([]),
216 getForUser: () =>214 getForUser: () =>
217 Effect.succeed({215 Effect.succeed({
infra/relay/src/environments/EnvironmentLinker.test.ts-2
...@@ -123,9 +123,7 @@ function testLayer(input?: {...@@ -123,9 +123,7 @@ function testLayer(input?: {
123 }),123 }),
124 Layer.succeed(EnvironmentLinks.EnvironmentLinks, {124 Layer.succeed(EnvironmentLinks.EnvironmentLinks, {
125 upsert: input?.upsert ?? (() => Effect.void),125 upsert: input?.upsert ?? (() => Effect.void),
126 listUsersForEnvironment: () => Effect.succeed([]),
127 listDeliveryUsersForEnvironment: () => Effect.succeed([]),126 listDeliveryUsersForEnvironment: () => Effect.succeed([]),
128 listPublicKeysForEnvironment: () => Effect.succeed([]),
129 listForUser: () => Effect.succeed([]),127 listForUser: () => Effect.succeed([]),
130 getForUser: () => Effect.succeed(null),128 getForUser: () => Effect.succeed(null),
131 revokeForUser: () => Effect.succeed(false),129 revokeForUser: () => Effect.succeed(false),
infra/relay/src/environments/EnvironmentLinks.test.ts+10-4
...@@ -66,7 +66,7 @@ describe("EnvironmentLinks", () => {...@@ -66,7 +66,7 @@ describe("EnvironmentLinks", () => {
6666
67 expect(error).toMatchObject({67 expect(error).toMatchObject({
68 _tag: "EnvironmentLinkUserListPersistenceError",68 _tag: "EnvironmentLinkUserListPersistenceError",
69 operation: "list-delivery-users",69 message: "Environment link user query 'list-delivery-users' failed for environment 'env-1'",
70 environmentId: "env-1",70 environmentId: "env-1",
71 });71 });
72 expect(error.cause).toBe(cause);72 expect(error.cause).toBe(cause);
...@@ -78,7 +78,7 @@ describe("EnvironmentLinks", () => {...@@ -78,7 +78,7 @@ describe("EnvironmentLinks", () => {
78 );78 );
79 });79 });
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", () => {
82 const whereConditions: Array<unknown> = [];82 const whereConditions: Array<unknown> = [];
83 const fakeDb = {83 const fakeDb = {
84 select: (selection: unknown) => {84 select: (selection: unknown) => {
...@@ -99,7 +99,12 @@ describe("EnvironmentLinks", () => {...@@ -99,7 +99,12 @@ describe("EnvironmentLinks", () => {
9999
100 return Effect.gen(function* () {100 return Effect.gen(function* () {
101 const links = yield* EnvironmentLinks.EnvironmentLinks;101 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([]);
103 expect(whereConditions).toHaveLength(1);108 expect(whereConditions).toHaveLength(1);
104109
105 const query = new PgDialect().sqlToQuery(whereConditions[0] as never);110 const query = new PgDialect().sqlToQuery(whereConditions[0] as never);
...@@ -107,8 +112,9 @@ describe("EnvironmentLinks", () => {...@@ -107,8 +112,9 @@ describe("EnvironmentLinks", () => {
107 expect(query.sql).toContain('"relay_environment_links"."revoked_at" is null');112 expect(query.sql).toContain('"relay_environment_links"."revoked_at" is null');
108 expect(query.sql).toContain('"relay_environment_links"."notifications_enabled" = $2');113 expect(query.sql).toContain('"relay_environment_links"."notifications_enabled" = $2');
109 expect(query.sql).toContain('"relay_environment_links"."live_activities_enabled" = $3');114 expect(query.sql).toContain('"relay_environment_links"."live_activities_enabled" = $3');
115 expect(query.sql).toContain('"relay_environment_links"."environment_public_key" = $4');
110 expect(query.sql).toContain(" or ");116 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"]);
112 }).pipe(118 }).pipe(
113 Effect.provide(119 Effect.provide(
114 EnvironmentLinks.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))),120 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...@@ -41,25 +41,12 @@ export class EnvironmentLinkUpsertPersistenceError extends Schema.TaggedError<En
41export class EnvironmentLinkUserListPersistenceError extends Schema.TaggedError<EnvironmentLinkUserListPersistenceError>()(41export class EnvironmentLinkUserListPersistenceError extends Schema.TaggedError<EnvironmentLinkUserListPersistenceError>()(
42 "EnvironmentLinkUserListPersistenceError",42 "EnvironmentLinkUserListPersistenceError",
43 {43 {
44 operation: Schema.Literals(["list-users", "list-delivery-users"]),
45 environmentId: Schema.String,44 environmentId: Schema.String,
46 cause: Schema.Defect(),45 cause: Schema.Defect(),
47 },46 },
48) {47) {
49 override get message(): string {48 override get message(): string {
50 return `Environment link user query '${this.operation}' failed for environment '${this.environmentId}'`;49 return `Environment link user query 'list-delivery-users' 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}'`;
63 }50 }
64}51}
6552
...@@ -110,9 +97,6 @@ export class EnvironmentLinks extends Context.Service<...@@ -110,9 +97,6 @@ export class EnvironmentLinks extends Context.Service<
110 readonly proof: RelayEnvironmentLinkProofPayload;97 readonly proof: RelayEnvironmentLinkProofPayload;
111 readonly endpoint: RelayManagedEndpoint;98 readonly endpoint: RelayManagedEndpoint;
112 }) => Effect.Effect<void, EnvironmentLinkUpsertPersistenceError>;99 }) => Effect.Effect<void, EnvironmentLinkUpsertPersistenceError>;
113 readonly listUsersForEnvironment: (input: {
114 readonly environmentId: string;
115 }) => Effect.Effect<ReadonlyArray<string>, EnvironmentLinkUserListPersistenceError>;
116 readonly listDeliveryUsersForEnvironment: (input: {100 readonly listDeliveryUsersForEnvironment: (input: {
117 readonly environmentId: string;101 readonly environmentId: string;
118 readonly environmentPublicKey: string;102 readonly environmentPublicKey: string;
...@@ -120,9 +104,6 @@ export class EnvironmentLinks extends Context.Service<...@@ -120,9 +104,6 @@ export class EnvironmentLinks extends Context.Service<
120 ReadonlyArray<AgentAwarenessDeliveryUserRecord>,104 ReadonlyArray<AgentAwarenessDeliveryUserRecord>,
121 EnvironmentLinkUserListPersistenceError105 EnvironmentLinkUserListPersistenceError
122 >;106 >;
123 readonly listPublicKeysForEnvironment: (input: {
124 readonly environmentId: string;
125 }) => Effect.Effect<ReadonlyArray<string>, EnvironmentPublicKeyListPersistenceError>;
126 readonly listForUser: (input: {107 readonly listForUser: (input: {
127 readonly userId: string;108 readonly userId: string;
128 }) => Effect.Effect<109 }) => Effect.Effect<
...@@ -140,23 +121,17 @@ export class EnvironmentLinks extends Context.Service<...@@ -140,23 +121,17 @@ export class EnvironmentLinks extends Context.Service<
140 }121 }
141>()("t3code-relay/environments/EnvironmentLinks") {}122>()("t3code-relay/environments/EnvironmentLinks") {}
142123
143function agentAwarenessDeliveryUserCondition(environmentId: string) {124function agentAwarenessDeliveryUserKeyCondition(input: {
125 readonly environmentId: string;
126 readonly environmentPublicKey: string;
127}) {
144 return and(128 return and(
145 eq(relayEnvironmentLinks.environmentId, environmentId),129 eq(relayEnvironmentLinks.environmentId, input.environmentId),
146 isNull(relayEnvironmentLinks.revokedAt),130 isNull(relayEnvironmentLinks.revokedAt),
147 or(131 or(
148 eq(relayEnvironmentLinks.notificationsEnabled, true),132 eq(relayEnvironmentLinks.notificationsEnabled, true),
149 eq(relayEnvironmentLinks.liveActivitiesEnabled, true),133 eq(relayEnvironmentLinks.liveActivitiesEnabled, true),
150 ),134 ),
151 );
152}
153
154function agentAwarenessDeliveryUserKeyCondition(input: {
155 readonly environmentId: string;
156 readonly environmentPublicKey: string;
157}) {
158 return and(
159 agentAwarenessDeliveryUserCondition(input.environmentId),
160 eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey),135 eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey),
161 );136 );
162}137}
...@@ -220,27 +195,6 @@ const make = Effect.gen(function* () {...@@ -220,27 +195,6 @@ const make = Effect.gen(function* () {
220 );195 );
221 }),196 }),
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
244 listDeliveryUsersForEnvironment: Effect.fn(198 listDeliveryUsersForEnvironment: Effect.fn(
245 "relay.environment_links.list_delivery_users_for_environment",199 "relay.environment_links.list_delivery_users_for_environment",
246 )(function* (input) {200 )(function* (input) {
...@@ -264,34 +218,6 @@ const make = Effect.gen(function* () {...@@ -264,34 +218,6 @@ const make = Effect.gen(function* () {
264 Effect.mapError(218 Effect.mapError(
265 (cause) =>219 (cause) =>
266 new EnvironmentLinkUserListPersistenceError({220 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({
295 environmentId: input.environmentId,221 environmentId: input.environmentId,
296 cause,222 cause,
297 }),223 }),
infra/relay/src/http/Api.test.ts-2
...@@ -319,9 +319,7 @@ function relayUnlinkTestLayer(input?: {...@@ -319,9 +319,7 @@ function relayUnlinkTestLayer(input?: {
319 EnvironmentLinks.EnvironmentLinks,319 EnvironmentLinks.EnvironmentLinks,
320 EnvironmentLinks.EnvironmentLinks.of({320 EnvironmentLinks.EnvironmentLinks.of({
321 upsert: () => Effect.die("unused upsert"),321 upsert: () => Effect.die("unused upsert"),
322 listUsersForEnvironment: () => Effect.die("unused listUsersForEnvironment"),
323 listDeliveryUsersForEnvironment: () => Effect.die("unused listDeliveryUsersForEnvironment"),322 listDeliveryUsersForEnvironment: () => Effect.die("unused listDeliveryUsersForEnvironment"),
324 listPublicKeysForEnvironment: () => Effect.die("unused listPublicKeysForEnvironment"),
325 listForUser: () => Effect.die("unused listForUser"),323 listForUser: () => Effect.die("unused listForUser"),
326 getForUser: input?.getForUser ?? (() => Effect.succeed(null)),324 getForUser: input?.getForUser ?? (() => Effect.succeed(null)),
327 revokeForUser: input?.revokeForUser ?? (() => Effect.succeed(false)),325 revokeForUser: input?.revokeForUser ?? (() => Effect.succeed(false)),
infra/relay/src/http/Api.ts-1
...@@ -1041,7 +1041,6 @@ const RelayCommonPersistenceError = Schema.Union([...@@ -1041,7 +1041,6 @@ const RelayCommonPersistenceError = Schema.Union([
1041 Devices.DeviceListPersistenceError,1041 Devices.DeviceListPersistenceError,
1042 LiveActivities.LiveActivityRegistrationPersistenceError,1042 LiveActivities.LiveActivityRegistrationPersistenceError,
1043 EnvironmentLinks.EnvironmentLinkUserListPersistenceError,1043 EnvironmentLinks.EnvironmentLinkUserListPersistenceError,
1044 EnvironmentLinks.EnvironmentPublicKeyListPersistenceError,
1045 EnvironmentLinks.EnvironmentLinkListPersistenceError,1044 EnvironmentLinks.EnvironmentLinkListPersistenceError,
1046 EnvironmentLinks.EnvironmentLinkLookupPersistenceError,1045 EnvironmentLinks.EnvironmentLinkLookupPersistenceError,
1047 EnvironmentLinks.EnvironmentLinkRevokePersistenceError,1046 EnvironmentLinks.EnvironmentLinkRevokePersistenceError,
packages/client-runtime/src/authorization/remote.test.ts-40
...@@ -10,7 +10,6 @@ import {...@@ -10,7 +10,6 @@ import {
10 appendClientConnectionParams,10 appendClientConnectionParams,
11 bootstrapRemoteBearerSession,11 bootstrapRemoteBearerSession,
12 exchangeRemoteDpopAccessToken,12 exchangeRemoteDpopAccessToken,
13 fetchRemoteDpopSessionState,
14 fetchRemoteSessionState,13 fetchRemoteSessionState,
15 issueRemoteDpopWebSocketTicket,14 issueRemoteDpopWebSocketTicket,
16 issueRemoteWebSocketTicket,15 issueRemoteWebSocketTicket,
...@@ -380,45 +379,6 @@ describe("remote environment authorization", () => {...@@ -380,45 +379,6 @@ describe("remote environment authorization", () => {
380 }),379 }),
381 );380 );
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
422 it.effect("fails hung fetch requests on the configured timeout", () =>382 it.effect("fails hung fetch requests on the configured timeout", () =>
423 Effect.gen(function* () {383 Effect.gen(function* () {
424 const fetch = hangingFetch();384 const fetch = hangingFetch();
packages/client-runtime/src/authorization/remote.ts-21
...@@ -158,27 +158,6 @@ export const fetchRemoteSessionState = Effect.fn(...@@ -158,27 +158,6 @@ export const fetchRemoteSessionState = Effect.fn(
158 );158 );
159});159});
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
182export const issueRemoteWebSocketTicket = Effect.fn(161export const issueRemoteWebSocketTicket = Effect.fn(
183 "clientRuntime.authorization.issueRemoteWebSocketTicket",162 "clientRuntime.authorization.issueRemoteWebSocketTicket",
184)(function* (input: {163)(function* (input: {
packages/contracts/src/environment.ts-14
...@@ -189,14 +189,6 @@ export const ExecutionEnvironmentDescriptor = Schema.Struct({...@@ -189,14 +189,6 @@ export const ExecutionEnvironmentDescriptor = Schema.Struct({
189});189});
190export type ExecutionEnvironmentDescriptor = typeof ExecutionEnvironmentDescriptor.Type;190export 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
200export const RepositoryIdentityLocator = Schema.Struct({192export const RepositoryIdentityLocator = Schema.Struct({
201 source: Schema.Literal("git-remote"),193 source: Schema.Literal("git-remote"),
202 remoteName: TrimmedNonEmptyString,194 remoteName: TrimmedNonEmptyString,
...@@ -228,9 +220,3 @@ export const ScopedThreadRef = Schema.Struct({...@@ -228,9 +220,3 @@ export const ScopedThreadRef = Schema.Struct({
228 threadId: ThreadId,220 threadId: ThreadId,
229});221});
230export type ScopedThreadRef = typeof ScopedThreadRef.Type;222export 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 @@...@@ -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";
51import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
52import type {2
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";
65import {3import {
66 PreviewAutomationClickInput,4 PreviewAutomationClickInput,
67 PreviewAutomationEvaluateInput,5 PreviewAutomationEvaluateInput,
68 PreviewAutomationHost,
69 PreviewAutomationHostFocus,
70 PreviewAutomationPressInput,6 PreviewAutomationPressInput,
71 PreviewAutomationResponse,
72 PreviewAutomationScrollInput,7 PreviewAutomationScrollInput,
73 PreviewAutomationSnapshot,8 PreviewAutomationSnapshot,
74 PreviewAutomationStatus,9 PreviewAutomationStatus,
75 PreviewAutomationStreamEvent,
76 PreviewAutomationTypeInput,10 PreviewAutomationTypeInput,
77 PreviewAutomationWaitForInput,11 PreviewAutomationWaitForInput,
78} from "./previewAutomation.ts";12} 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";
90import { SnapShotSource } from "./orchestration.ts";13import { SnapShotSource } from "./orchestration.ts";
91import { EnvironmentId, TrimmedNonEmptyString } from "./baseSchemas.ts";14import { EnvironmentId, TrimmedNonEmptyString } from "./baseSchemas.ts";
92import { BrowserProfileId } from "./browserProfile.ts";15import { BrowserProfileId } from "./browserProfile.ts";
...@@ -100,14 +23,7 @@ import { AdvertisedEndpoint } from "./remoteAccess.ts";...@@ -100,14 +23,7 @@ import { AdvertisedEndpoint } from "./remoteAccess.ts";
100import { ExecutionEnvironmentDescriptor } from "./environment.ts";23import { ExecutionEnvironmentDescriptor } from "./environment.ts";
101import { type ClientSettings, type QuitConfirmationMode, SnapShotShortcut } from "./settings.ts";24import { type ClientSettings, type QuitConfirmationMode, SnapShotShortcut } from "./settings.ts";
102import type { EditorId } from "./editor.ts";25import type { EditorId } from "./editor.ts";
103import type {26
104 SourceControlCloneRepositoryInput,
105 SourceControlCloneRepositoryResult,
106 SourceControlPublishRepositoryInput,
107 SourceControlPublishRepositoryResult,
108 SourceControlRepositoryInfo,
109 SourceControlRepositoryLookupInput,
110} from "./sourceControl.ts";
111import type {27import type {
112 DesktopAppActivationRequest,28 DesktopAppActivationRequest,
113 DesktopAppActivationResponse,29 DesktopAppActivationResponse,
...@@ -694,19 +610,6 @@ export interface DesktopPreviewFavicon {...@@ -694,19 +610,6 @@ export interface DesktopPreviewFavicon {
694 capturedAt: number;610 capturedAt: number;
695}611}
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
710export interface DesktopPreviewTabState {613export interface DesktopPreviewTabState {
711 tabId: string;614 tabId: string;
712 webContentsId: number | null;615 webContentsId: number | null;
...@@ -746,27 +649,6 @@ export const DesktopPreviewAutomationStatusSchema = Schema.Struct({...@@ -746,27 +649,6 @@ export const DesktopPreviewAutomationStatusSchema = Schema.Struct({
746});649});
747export type DesktopPreviewAutomationStatus = typeof DesktopPreviewAutomationStatusSchema.Type;650export 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
770export interface DesktopPreviewPointerEvent {652export interface DesktopPreviewPointerEvent {
771 tabId: string;653 tabId: string;
772 phase: "move" | "click";654 phase: "move" | "click";
...@@ -1474,135 +1356,3 @@ export interface LocalApi {...@@ -1474,135 +1356,3 @@ export interface LocalApi {
1474 setClientSettings: (settings: ClientSettings) => Promise<void>;1356 setClientSettings: (settings: ClientSettings) => Promise<void>;
1475 };1357 };
1476}1358}
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([...@@ -2208,26 +2208,6 @@ export const ProviderSessionRuntimeStatus = Schema.Literals([
2208]);2208]);
2209export type ProviderSessionRuntimeStatus = typeof ProviderSessionRuntimeStatus.Type;2209export 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
2231export const ProjectionPendingApprovalStatus = Schema.Literals(["pending", "resolved"]);2211export const ProjectionPendingApprovalStatus = Schema.Literals(["pending", "resolved"]);
2232export type ProjectionPendingApprovalStatus = typeof ProjectionPendingApprovalStatus.Type;2212export type ProjectionPendingApprovalStatus = typeof ProjectionPendingApprovalStatus.Type;
22332213
packages/contracts/src/providerRuntime.test.ts+2-10
...@@ -1,19 +1,11 @@...@@ -1,19 +1,11 @@
1import { describe, expect, expectTypeOf, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
33
4import {4import { classifyTaskAgentKind, ProviderRuntimeEvent } from "./providerRuntime.ts";
5 classifyTaskAgentKind,
6 ProviderRuntimeEvent,
7 type ProviderRuntimeEventType,
8} from "./providerRuntime.ts";
95
10const decodeRuntimeEvent = Schema.decodeUnknownSync(ProviderRuntimeEvent);6const decodeRuntimeEvent = Schema.decodeUnknownSync(ProviderRuntimeEvent);
117
12describe("ProviderRuntimeEvent", () => {8describe("ProviderRuntimeEvent", () => {
13 it("includes every runtime event in the public event type", () => {
14 expectTypeOf<ProviderRuntimeEvent["type"]>().toEqualTypeOf<ProviderRuntimeEventType>();
15 });
16
17 it("requires input and output totals for complete turn usage", () => {9 it("requires input and output totals for complete turn usage", () => {
18 const completeEvent = {10 const completeEvent = {
19 type: "turn.completed",11 type: "turn.completed",
packages/contracts/src/providerRuntime.ts-53
...@@ -148,59 +148,6 @@ export const CanonicalRequestType = Schema.Literals([...@@ -148,59 +148,6 @@ export const CanonicalRequestType = Schema.Literals([
148]);148]);
149export type CanonicalRequestType = typeof CanonicalRequestType.Type;149export 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
204const SessionStartedType = Schema.Literal("session.started");151const SessionStartedType = Schema.Literal("session.started");
205const SessionConfiguredType = Schema.Literal("session.configured");152const SessionConfiguredType = Schema.Literal("session.configured");
206const SessionStateChangedType = Schema.Literal("session.state.changed");153const 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 {...@@ -413,7 +413,7 @@ function addAsyncQuestionFields(value: Schema.Json): Schema.Json {
413 return {413 return {
414 ...value,414 ...value,
415 properties: {415 properties: {
416 ...properties,416 ...Object.fromEntries(Object.entries(properties).filter(([key]) => key !== "type")),
417 delivery: { anyOf: [{ type: "string", enum: ["async"] }, { type: "null" }] },417 delivery: { anyOf: [{ type: "string", enum: ["async"] }, { type: "null" }] },
418 questions: {418 questions: {
419 anyOf: [419 anyOf: [
...@@ -433,6 +433,7 @@ function addAsyncQuestionFields(value: Schema.Json): Schema.Json {...@@ -433,6 +433,7 @@ function addAsyncQuestionFields(value: Schema.Json): Schema.Json {
433 { type: "null" },433 { type: "null" },
434 ],434 ],
435 },435 },
436 type: itemType,
436 },437 },
437 };438 };
438 }439 }
...@@ -760,7 +761,8 @@ const generateFiles = Effect.fn("generateFiles")(function* () {...@@ -760,7 +761,8 @@ const generateFiles = Effect.fn("generateFiles")(function* () {
760 for (const [name, schema] of Object.entries(aggregateSchemas).toSorted(([left], [right]) =>761 for (const [name, schema] of Object.entries(aggregateSchemas).toSorted(([left], [right]) =>
761 left.localeCompare(right),762 left.localeCompare(right),
762 )) {763 )) {
763 generator.addSchema(name, addAsyncQuestionFields(schema) as never);764 aggregateSchemas[name] = addAsyncQuestionFields(schema);
765 generator.addSchema(name, aggregateSchemas[name] as never);
764 }766 }
765767
766 const generatedEntries = new Map<string, string>();768 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";...@@ -5,7 +5,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
55
6import * as SqliteClient from "./nodeSqliteClient.ts";6import * as SqliteClient from "./nodeSqliteClient.ts";
77
8const layer = it.layer(SqliteClient.layerMemory());8const layer = it.layer(SqliteClient.layer({ filename: ":memory:" }));
99
10layer("NodeSqliteClient", (it) => {10layer("NodeSqliteClient", (it) => {
11 it.effect("runs prepared queries and returns positional values", () =>11 it.effect("runs prepared queries and returns positional values", () =>
packages/shared/src/nodeSqliteClient.ts+6-42
...@@ -36,11 +36,6 @@ export interface SqliteClientConfig {...@@ -36,11 +36,6 @@ export interface SqliteClientConfig {
36 readonly transformQueryNames?: ((str: string) => string) | undefined;36 readonly transformQueryNames?: ((str: string) => string) | undefined;
37}37}
3838
39export interface SqliteMemoryClientConfig extends Omit<
40 SqliteClientConfig,
41 "filename" | "readonly"
42> {}
43
44export class UnsupportedNodeSqliteVersionError extends Schema.TaggedError<UnsupportedNodeSqliteVersionError>()(39export class UnsupportedNodeSqliteVersionError extends Schema.TaggedError<UnsupportedNodeSqliteVersionError>()(
45 "UnsupportedNodeSqliteVersionError",40 "UnsupportedNodeSqliteVersionError",
46 {41 {
...@@ -86,9 +81,8 @@ const checkNodeSqliteCompat = () => {...@@ -86,9 +81,8 @@ const checkNodeSqliteCompat = () => {
86 return Effect.void;81 return Effect.void;
87};82};
8883
89const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (84const make = Effect.fn("makeWithDatabase")(function* (
90 options: SqliteClientConfig,85 options: SqliteClientConfig,
91 openDatabase: () => NodeSqlite.DatabaseSync,
92): Effect.fn.Return<Client.SqlClient, SqlError, Scope.Scope | Reactivity.Reactivity> {86): Effect.fn.Return<Client.SqlClient, SqlError, Scope.Scope | Reactivity.Reactivity> {
93 yield* checkNodeSqliteCompat();87 yield* checkNodeSqliteCompat();
9488
...@@ -100,7 +94,11 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (...@@ -100,7 +94,11 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (
100 const makeConnection = Effect.gen(function* () {94 const makeConnection = Effect.gen(function* () {
101 const scope = yield* Effect.scope;95 const scope = yield* Effect.scope;
102 const db = yield* Effect.try({96 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 }),
104 catch: (cause) =>102 catch: (cause) =>
105 new SqlError({103 new SqlError({
106 reason: classifySqliteError(cause, {104 reason: classifySqliteError(cause, {
...@@ -281,39 +279,5 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (...@@ -281,39 +279,5 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (
281 });279 });
282});280});
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
313export const layer = (config: SqliteClientConfig): Layer.Layer<Client.SqlClient, SqlError> =>282export const layer = (config: SqliteClientConfig): Layer.Layer<Client.SqlClient, SqlError> =>
314 Layer.effect(Client.SqlClient, make(config)).pipe(Layer.provide(Reactivity.layer));283 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";...@@ -11,7 +11,6 @@ import * as Path from "effect/Path";
1111
12import { HostProcessEnvironment, HostProcessPlatform } from "./hostProcess.ts";12import { HostProcessEnvironment, HostProcessPlatform } from "./hostProcess.ts";
13import * as Context from "effect/Context";13import * as Context from "effect/Context";
14
15const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/;14const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/;
16const WINDOWS_PATH_DELIMITER = ";";15const WINDOWS_PATH_DELIMITER = ";";
17const POSIX_PATH_DELIMITER = ":";16const POSIX_PATH_DELIMITER = ":";
packages/tailscale/src/tailscale.ts+5-7
...@@ -111,13 +111,11 @@ export class TailscaleCommandTimeoutError extends Schema.TaggedError<TailscaleCo...@@ -111,13 +111,11 @@ export class TailscaleCommandTimeoutError extends Schema.TaggedError<TailscaleCo
111 }111 }
112}112}
113113
114export const TailscaleCommandError = Schema.Union([114export type TailscaleCommandError =
115 TailscaleCommandSpawnError,115 | TailscaleCommandSpawnError
116 TailscaleCommandOutputError,116 | TailscaleCommandOutputError
117 TailscaleCommandExitError,117 | TailscaleCommandExitError
118 TailscaleCommandTimeoutError,118 | TailscaleCommandTimeoutError;
119]);
120export type TailscaleCommandError = typeof TailscaleCommandError.Type;
121119
122export class TailscaleStatusParseError extends Schema.TaggedError<TailscaleStatusParseError>()(120export class TailscaleStatusParseError extends Schema.TaggedError<TailscaleStatusParseError>()(
123 "TailscaleStatusParseError",121 "TailscaleStatusParseError",
scripts/dev-runner.ts-11
...@@ -190,17 +190,6 @@ export class DevRunnerHostNotProxiableError extends Schema.TaggedError<DevRunner...@@ -190,17 +190,6 @@ export class DevRunnerHostNotProxiableError extends Schema.TaggedError<DevRunner
190 }190 }
191}191}
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
204const optionalStringConfig = (name: string): Config.Config<string | undefined> =>193const optionalStringConfig = (name: string): Config.Config<string | undefined> =>
205 Config.String(name).pipe(194 Config.String(name).pipe(
206 Config.option,195 Config.option,
scripts/lib/dev-share.ts+4-7
...@@ -110,13 +110,10 @@ export class DevServeFailedError extends Schema.TaggedError<DevServeFailedError>...@@ -110,13 +110,10 @@ export class DevServeFailedError extends Schema.TaggedError<DevServeFailedError>
110 }110 }
111}111}
112112
113export const DevShareError = Schema.Union([113export type DevShareError =
114 TailscaleUnavailableError,114 | TailscaleUnavailableError
115 TailnetNameMissingError,115 | TailnetNameMissingError
116 DevServeFailedError,116 | DevServeFailedError;
117]);
118export type DevShareError = typeof DevShareError.Type;
119export const isDevShareError = Schema.is(DevShareError);
120117
121/**118/**
122 * Removes any mapping for `webPort`, reporting whether the port is now clear.119 * Removes any mapping for `webPort`, reporting whether the port is now clear.
scripts/mobile-showcase.ts-13
...@@ -26,8 +26,6 @@ import showcaseConfig, {...@@ -26,8 +26,6 @@ import showcaseConfig, {
26import {26import {
27 SHOWCASE_ENVIRONMENTS,27 SHOWCASE_ENVIRONMENTS,
28 SHOWCASE_PROJECTS,28 SHOWCASE_PROJECTS,
29 SHOWCASE_TERMINAL_ID,
30 SHOWCASE_THREAD_ID,
31 seedShowcaseEnvironment,29 seedShowcaseEnvironment,
32} from "./mobile-showcase-environment.ts";30} from "./mobile-showcase-environment.ts";
3331
...@@ -668,17 +666,6 @@ function buildShowcasePairingUrl(host: string, port: number, credential: string)...@@ -668,17 +666,6 @@ function buildShowcasePairingUrl(host: string, port: number, credential: string)
668 return url.toString();666 return url.toString();
669}667}
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
682export function encodeAndroidPairingUrls(pairingUrls: ReadonlyArray<string>): string {669export function encodeAndroidPairingUrls(pairingUrls: ReadonlyArray<string>): string {
683 return `json-uri:${encodeURIComponent(JSON.stringify(pairingUrls))}`;670 return `json-uri:${encodeURIComponent(JSON.stringify(pairingUrls))}`;
684}671}
scripts/notify-discord-release.test.ts+1-7
...@@ -3,11 +3,7 @@ import * as Effect from "effect/Effect";...@@ -3,11 +3,7 @@ import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
4import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";4import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
55
6import {6import { buildDiscordReleaseAnnouncement, postDiscordWebhook } from "./notify-discord-release.ts";
7 buildDiscordReleaseAnnouncement,
8 isDiscordReleaseAnnouncementError,
9 postDiscordWebhook,
10} from "./notify-discord-release.ts";
117
12const latestAnnouncement = {8const latestAnnouncement = {
13 target: "latest",9 target: "latest",
...@@ -135,7 +131,6 @@ it.effect("preserves webhook request context and the full client cause chain", (...@@ -135,7 +131,6 @@ it.effect("preserves webhook request context and the full client cause chain", (
135 assert.equal(error.cause, clientError);131 assert.equal(error.cause, clientError);
136 assert.equal((error.cause as HttpClientError.HttpClientError).cause, requestCause);132 assert.equal((error.cause as HttpClientError.HttpClientError).cause, requestCause);
137 assert.ok(!error.message.includes(requestCause.message));133 assert.ok(!error.message.includes(requestCause.message));
138 assert.equal(isDiscordReleaseAnnouncementError(error), true);
139 });134 });
140});135});
141136
...@@ -169,6 +164,5 @@ it.effect("preserves a non-success response error with structured status context...@@ -169,6 +164,5 @@ it.effect("preserves a non-success response error with structured status context
169 }164 }
170 assert.equal(error.cause.reason._tag, "StatusCodeError");165 assert.equal(error.cause.reason._tag, "StatusCodeError");
171 assert.ok(!error.message.includes(error.cause.message));166 assert.ok(!error.message.includes(error.cause.message));
172 assert.equal(isDiscordReleaseAnnouncementError(error), true);
173 });167 });
174});168});
scripts/notify-discord-release.ts-7
...@@ -90,13 +90,6 @@ export class DiscordReleaseWebhookResponseError extends Schema.TaggedError<Disco...@@ -90,13 +90,6 @@ export class DiscordReleaseWebhookResponseError extends Schema.TaggedError<Disco
90 }90 }
91}91}
9292
93export const DiscordReleaseAnnouncementError = Schema.Union([
94 DiscordReleaseWebhookRequestError,
95 DiscordReleaseWebhookResponseError,
96]);
97export type DiscordReleaseAnnouncementError = typeof DiscordReleaseAnnouncementError.Type;
98export const isDiscordReleaseAnnouncementError = Schema.is(DiscordReleaseAnnouncementError);
99
100const targetLabels = {93const targetLabels = {
101 prerelease: "Prerelease",94 prerelease: "Prerelease",
102 latest: "Latest",95 latest: "Latest",
scripts/sync-reference-repos.ts+5-8
...@@ -91,14 +91,11 @@ export class ReferenceRepoGitSubtreeError extends Schema.TaggedError<ReferenceRe...@@ -91,14 +91,11 @@ export class ReferenceRepoGitSubtreeError extends Schema.TaggedError<ReferenceRe
91 }91 }
92}92}
9393
94export const ReferenceRepoSyncError = Schema.Union([94export type ReferenceRepoSyncError =
95 ReferenceRepoSelectionError,95 | ReferenceRepoSelectionError
96 ReferenceRepoVersionSourceError,96 | ReferenceRepoVersionSourceError
97 ReferenceRepoVersionResolutionError,97 | ReferenceRepoVersionResolutionError
98 ReferenceRepoGitSubtreeError,98 | ReferenceRepoGitSubtreeError;
99]);
100export type ReferenceRepoSyncError = typeof ReferenceRepoSyncError.Type;
101export const isReferenceRepoSyncError = Schema.is(ReferenceRepoSyncError);
10299
103const decodeJsonSource = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));100const decodeJsonSource = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
104const decodeYamlSource = Schema.decodeEffect(fromYaml(Schema.Unknown));101const decodeYamlSource = Schema.decodeEffect(fromYaml(Schema.Unknown));