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