| author | |
| committer | |
| log | 11f051373e79b38fa16f3ec1af825f5164907c1b |
| tree | b10d0efb3fa0d0fe2d85e16595f51161e4dada8c |
| parent | 035058a23e651ea5f407c9ab73cf5329cc40c102 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>22 files changed, 485 insertions(+), 22 deletions(-)
apps/mobile/src/connection/platform.ts+2-1| ... | @@ -21,6 +21,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -21,6 +21,7 @@ import * as Layer from "effect/Layer"; |
| 21 | import * as Option from "effect/Option"; | 21 | import * as Option from "effect/Option"; |
| 22 | import * as Queue from "effect/Queue"; | 22 | import * as Queue from "effect/Queue"; |
| 23 | import * as Stream from "effect/Stream"; | 23 | import * as Stream from "effect/Stream"; |
| 24 | import Constants from "expo-constants"; | ||
| 24 | import * as Network from "expo-network"; | 25 | import * as Network from "expo-network"; |
| 25 | import { AppState } from "react-native"; | 26 | import { AppState } from "react-native"; |
| 26 | 27 | ||
| ... | @@ -166,7 +167,7 @@ const capabilitiesLayer = Layer.effectContext( | ... | @@ -166,7 +167,7 @@ const capabilitiesLayer = Layer.effectContext( |
| 166 | Context.add( | 167 | Context.add( |
| 167 | ClientPresentation, | 168 | ClientPresentation, |
| 168 | ClientPresentation.of({ | 169 | ClientPresentation.of({ |
| 169 | metadata: authClientMetadata(), | 170 | metadata: authClientMetadata(Constants.expoConfig?.version), |
| 170 | scopes: AuthStandardClientScopes, | 171 | scopes: AuthStandardClientScopes, |
| 171 | }), | 172 | }), |
| 172 | ), | 173 | ), |
apps/mobile/src/lib/authClientMetadata.ts+3-1| ... | @@ -1,10 +1,12 @@ | ... | @@ -1,10 +1,12 @@ |
| 1 | import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; | 1 | import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; |
| 2 | import { Platform } from "react-native"; | 2 | import { Platform } from "react-native"; |
| 3 | 3 | ||
| 4 | export function authClientMetadata(): AuthClientPresentationMetadata { | 4 | export function authClientMetadata(appVersion?: string): AuthClientPresentationMetadata { |
| 5 | return { | 5 | return { |
| 6 | label: "T3 Code Mobile", | 6 | label: "T3 Code Mobile", |
| 7 | deviceType: "mobile", | 7 | deviceType: "mobile", |
| 8 | ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}), | 8 | ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}), |
| 9 | surface: "mobile", | ||
| 10 | ...(appVersion ? { appVersion } : {}), | ||
| 9 | }; | 11 | }; |
| 10 | } | 12 | } |
apps/mobile/src/lib/connection.test.ts+8| ... | @@ -26,6 +26,14 @@ describe("mobile remote connection records", () => { | ... | @@ -26,6 +26,14 @@ describe("mobile remote connection records", () => { |
| 26 | label: "T3 Code Mobile", | 26 | label: "T3 Code Mobile", |
| 27 | deviceType: "mobile", | 27 | deviceType: "mobile", |
| 28 | os: "iOS", | 28 | os: "iOS", |
| 29 | surface: "mobile", | ||
| 30 | }); | ||
| 31 | }); | ||
| 32 | |||
| 33 | it("includes the mobile app version when the client provides it", () => { | ||
| 34 | expect(authClientMetadata("1.2.3")).toMatchObject({ | ||
| 35 | surface: "mobile", | ||
| 36 | appVersion: "1.2.3", | ||
| 29 | }); | 37 | }); |
| 30 | }); | 38 | }); |
| 31 | 39 |
apps/server/src/auth/SessionStore.test.ts+33| ... | @@ -4,6 +4,7 @@ import * as Duration from "effect/Duration"; | ... | @@ -4,6 +4,7 @@ import * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as TestClock from "effect/testing/TestClock"; | 6 | import * as TestClock from "effect/testing/TestClock"; |
| 7 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 7 | 8 | ||
| 8 | import * as ServerConfig from "../config.ts"; | 9 | import * as ServerConfig from "../config.ts"; |
| 9 | import { PersistenceSqlError } from "../persistence/Errors.ts"; | 10 | import { PersistenceSqlError } from "../persistence/Errors.ts"; |
| ... | @@ -47,6 +48,7 @@ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessi | ... | @@ -47,6 +48,7 @@ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessi |
| 47 | revoke: () => Effect.fail(repositoryFailure), | 48 | revoke: () => Effect.fail(repositoryFailure), |
| 48 | revokeAllExcept: () => Effect.fail(repositoryFailure), | 49 | revokeAllExcept: () => Effect.fail(repositoryFailure), |
| 49 | setLastConnectedAt: () => Effect.void, | 50 | setLastConnectedAt: () => Effect.void, |
| 51 | setClientConnection: () => Effect.void, | ||
| 50 | }); | 52 | }); |
| 51 | 53 | ||
| 52 | const failingSessionLookupCredentialLayer = Layer.effect( | 54 | const failingSessionLookupCredentialLayer = Layer.effect( |
| ... | @@ -315,4 +317,35 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { | ... | @@ -315,4 +317,35 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { |
| 315 | expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString()); | 317 | expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString()); |
| 316 | }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), | 318 | }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), |
| 317 | ); | 319 | ); |
| 320 | it.effect("records client connection metadata without clearing prior values", () => | ||
| 321 | Effect.gen(function* () { | ||
| 322 | const sessions = yield* SessionStore.SessionStore; | ||
| 323 | const sql = yield* SqlClient.SqlClient; | ||
| 324 | const issued = yield* sessions.issue({ | ||
| 325 | subject: "client-connection-test", | ||
| 326 | method: "bearer-access-token", | ||
| 327 | }); | ||
| 328 | const readRow = sql<{ | ||
| 329 | readonly surface: string | null; | ||
| 330 | readonly appVersion: string | null; | ||
| 331 | }>` | ||
| 332 | SELECT client_surface AS "surface", client_app_version AS "appVersion" | ||
| 333 | FROM auth_sessions | ||
| 334 | WHERE session_id = ${issued.sessionId} | ||
| 335 | `; | ||
| 336 | |||
| 337 | yield* sessions.recordClientConnection(issued.sessionId, { | ||
| 338 | surface: "mobile", | ||
| 339 | appVersion: "1.2.0", | ||
| 340 | }); | ||
| 341 | expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.2.0" }); | ||
| 342 | |||
| 343 | // A partial report (old or minimal client) must not null out stored data. | ||
| 344 | yield* sessions.recordClientConnection(issued.sessionId, { appVersion: "1.3.0" }); | ||
| 345 | expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.3.0" }); | ||
| 346 | |||
| 347 | yield* sessions.recordClientConnection(issued.sessionId, {}); | ||
| 348 | expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.3.0" }); | ||
| 349 | }).pipe(Effect.provide(Layer.mergeAll(makeSessionStoreLayer(), SqlitePersistenceMemory))), | ||
| 350 | ); | ||
| 318 | }); | 351 | }); |
apps/server/src/auth/SessionStore.ts+31| ... | @@ -5,6 +5,7 @@ import { | ... | @@ -5,6 +5,7 @@ import { |
| 5 | type AuthClientMetadata, | 5 | type AuthClientMetadata, |
| 6 | type AuthClientSession, | 6 | type AuthClientSession, |
| 7 | type AuthEnvironmentScope, | 7 | type AuthEnvironmentScope, |
| 8 | type ClientSurface, | ||
| 8 | type ServerAuthSessionMethod, | 9 | type ServerAuthSessionMethod, |
| 9 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 10 | import * as Context from "effect/Context"; | 11 | import * as Context from "effect/Context"; |
| ... | @@ -396,6 +397,13 @@ export class SessionStore extends Context.Service< | ... | @@ -396,6 +397,13 @@ export class SessionStore extends Context.Service< |
| 396 | ) => Effect.Effect<number, SessionCredentialInternalError>; | 397 | ) => Effect.Effect<number, SessionCredentialInternalError>; |
| 397 | readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>; | 398 | readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>; |
| 398 | readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>; | 399 | readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>; |
| 400 | readonly recordClientConnection: ( | ||
| 401 | sessionId: AuthSessionId, | ||
| 402 | client: { | ||
| 403 | readonly surface?: ClientSurface | undefined; | ||
| 404 | readonly appVersion?: string | undefined; | ||
| 405 | }, | ||
| 406 | ) => Effect.Effect<void, never>; | ||
| 399 | } | 407 | } |
| 400 | >()("t3/auth/SessionStore") {} | 408 | >()("t3/auth/SessionStore") {} |
| 401 | 409 | ||
| ... | @@ -544,6 +552,28 @@ export const make = Effect.gen(function* () { | ... | @@ -544,6 +552,28 @@ export const make = Effect.gen(function* () { |
| 544 | Effect.withSpan("SessionStore.markConnected"), | 552 | Effect.withSpan("SessionStore.markConnected"), |
| 545 | ); | 553 | ); |
| 546 | 554 | ||
| 555 | // Best-effort: connection metadata must never block or fail a connect. | ||
| 556 | const recordClientConnection: SessionStore["Service"]["recordClientConnection"] = ( | ||
| 557 | sessionId, | ||
| 558 | client, | ||
| 559 | ) => | ||
| 560 | client.surface === undefined && client.appVersion === undefined | ||
| 561 | ? Effect.void | ||
| 562 | : authSessions | ||
| 563 | .setClientConnection({ | ||
| 564 | sessionId, | ||
| 565 | surface: client.surface ?? null, | ||
| 566 | appVersion: client.appVersion ?? null, | ||
| 567 | }) | ||
| 568 | .pipe( | ||
| 569 | Effect.catchCause((cause) => | ||
| 570 | Effect.logWarning("Failed to record session client connection metadata.").pipe( | ||
| 571 | Effect.annotateLogs({ sessionId, cause }), | ||
| 572 | ), | ||
| 573 | ), | ||
| 574 | Effect.withSpan("SessionStore.recordClientConnection"), | ||
| 575 | ); | ||
| 576 | |||
| 547 | const markDisconnected: SessionStore["Service"]["markDisconnected"] = (sessionId) => | 577 | const markDisconnected: SessionStore["Service"]["markDisconnected"] = (sessionId) => |
| 548 | Ref.update(connectedSessionsRef, (current) => { | 578 | Ref.update(connectedSessionsRef, (current) => { |
| 549 | const next = new Map(current); | 579 | const next = new Map(current); |
| ... | @@ -912,6 +942,7 @@ export const make = Effect.gen(function* () { | ... | @@ -912,6 +942,7 @@ export const make = Effect.gen(function* () { |
| 912 | revokeAllExcept, | 942 | revokeAllExcept, |
| 913 | markConnected, | 943 | markConnected, |
| 914 | markDisconnected, | 944 | markDisconnected, |
| 945 | recordClientConnection, | ||
| 915 | }); | 946 | }); |
| 916 | }); | 947 | }); |
| 917 | 948 |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+51| ... | @@ -1379,4 +1379,55 @@ describe("OrchestrationEngine", () => { | ... | @@ -1379,4 +1379,55 @@ describe("OrchestrationEngine", () => { |
| 1379 | 1379 | ||
| 1380 | await system.dispose(); | 1380 | await system.dispose(); |
| 1381 | }); | 1381 | }); |
| 1382 | |||
| 1383 | it("stamps the dispatching client's origin onto persisted event metadata", async () => { | ||
| 1384 | const createdAt = now(); | ||
| 1385 | const system = await createOrchestrationSystem(); | ||
| 1386 | const { engine } = system; | ||
| 1387 | |||
| 1388 | await system.run( | ||
| 1389 | engine.dispatch( | ||
| 1390 | { | ||
| 1391 | type: "project.create", | ||
| 1392 | commandId: CommandId.make("cmd-origin-project-create"), | ||
| 1393 | projectId: asProjectId("project-origin"), | ||
| 1394 | title: "Origin Project", | ||
| 1395 | workspaceRoot: "/tmp/project-origin", | ||
| 1396 | defaultModelSelection: { | ||
| 1397 | instanceId: ProviderInstanceId.make("codex"), | ||
| 1398 | model: "gpt-5-codex", | ||
| 1399 | }, | ||
| 1400 | createdAt, | ||
| 1401 | }, | ||
| 1402 | { origin: { surface: "mobile", appVersion: "1.2.3" } }, | ||
| 1403 | ), | ||
| 1404 | ); | ||
| 1405 | await system.run( | ||
| 1406 | engine.dispatch({ | ||
| 1407 | type: "project.create", | ||
| 1408 | commandId: CommandId.make("cmd-no-origin-project-create"), | ||
| 1409 | projectId: asProjectId("project-no-origin"), | ||
| 1410 | title: "No Origin Project", | ||
| 1411 | workspaceRoot: "/tmp/project-no-origin", | ||
| 1412 | defaultModelSelection: { | ||
| 1413 | instanceId: ProviderInstanceId.make("codex"), | ||
| 1414 | model: "gpt-5-codex", | ||
| 1415 | }, | ||
| 1416 | createdAt, | ||
| 1417 | }), | ||
| 1418 | ); | ||
| 1419 | |||
| 1420 | const events = await system.run( | ||
| 1421 | Stream.runCollect(engine.readEvents(0)).pipe(Effect.map((chunk) => Array.from(chunk))), | ||
| 1422 | ); | ||
| 1423 | const withOrigin = events.find((event) => event.commandId === "cmd-origin-project-create"); | ||
| 1424 | const withoutOrigin = events.find( | ||
| 1425 | (event) => event.commandId === "cmd-no-origin-project-create", | ||
| 1426 | ); | ||
| 1427 | |||
| 1428 | expect(withOrigin?.metadata.origin).toEqual({ surface: "mobile", appVersion: "1.2.3" }); | ||
| 1429 | expect(withoutOrigin?.metadata.origin).toBeUndefined(); | ||
| 1430 | |||
| 1431 | await system.dispose(); | ||
| 1432 | }); | ||
| 1382 | }); | 1433 | }); |
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+14-2| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import type { | 1 | import type { |
| 2 | OrchestrationClientOrigin, | ||
| 2 | OrchestrationEvent, | 3 | OrchestrationEvent, |
| 3 | OrchestrationReadModel, | 4 | OrchestrationReadModel, |
| 4 | ProjectId, | 5 | ProjectId, |
| ... | @@ -54,6 +55,7 @@ const isOrchestrationCommandInvariantError = Schema.is(OrchestrationCommandInvar | ... | @@ -54,6 +55,7 @@ const isOrchestrationCommandInvariantError = Schema.is(OrchestrationCommandInvar |
| 54 | 55 | ||
| 55 | interface CommandEnvelope { | 56 | interface CommandEnvelope { |
| 56 | command: OrchestrationCommand; | 57 | command: OrchestrationCommand; |
| 58 | origin: OrchestrationClientOrigin | undefined; | ||
| 57 | result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>; | 59 | result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>; |
| 58 | startedAtMs: number; | 60 | startedAtMs: number; |
| 59 | } | 61 | } |
| ... | @@ -182,7 +184,16 @@ const makeOrchestrationEngine = Effect.gen(function* () { | ... | @@ -182,7 +184,16 @@ const makeOrchestrationEngine = Effect.gen(function* () { |
| 182 | }), | 184 | }), |
| 183 | ), | 185 | ), |
| 184 | ); | 186 | ); |
| 185 | const eventBases = Array.isArray(eventBase) ? eventBase : [eventBase]; | 187 | const plannedEvents = Array.isArray(eventBase) ? eventBase : [eventBase]; |
| 188 | // Stamp the dispatching client's origin onto every event the command | ||
| 189 | // produced. The decider stays pure; attribution is an engine concern. | ||
| 190 | const eventBases = | ||
| 191 | envelope.origin === undefined | ||
| 192 | ? plannedEvents | ||
| 193 | : plannedEvents.map((planned) => ({ | ||
| 194 | ...planned, | ||
| 195 | metadata: { ...planned.metadata, origin: envelope.origin }, | ||
| 196 | })); | ||
| 186 | const committedCommand = yield* sql | 197 | const committedCommand = yield* sql |
| 187 | .withTransaction( | 198 | .withTransaction( |
| 188 | Effect.gen(function* () { | 199 | Effect.gen(function* () { |
| ... | @@ -329,11 +340,12 @@ const makeOrchestrationEngine = Effect.gen(function* () { | ... | @@ -329,11 +340,12 @@ const makeOrchestrationEngine = Effect.gen(function* () { |
| 329 | const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive, limit) => | 340 | const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive, limit) => |
| 330 | eventStore.readFromSequence(fromSequenceExclusive, limit); | 341 | eventStore.readFromSequence(fromSequenceExclusive, limit); |
| 331 | 342 | ||
| 332 | const dispatch: OrchestrationEngineShape["dispatch"] = (command) => | 343 | const dispatch: OrchestrationEngineShape["dispatch"] = (command, options) => |
| 333 | Effect.gen(function* () { | 344 | Effect.gen(function* () { |
| 334 | const result = yield* Deferred.make<{ sequence: number }, OrchestrationDispatchError>(); | 345 | const result = yield* Deferred.make<{ sequence: number }, OrchestrationDispatchError>(); |
| 335 | yield* Queue.offer(commandQueue, { | 346 | yield* Queue.offer(commandQueue, { |
| 336 | command, | 347 | command, |
| 348 | origin: options?.origin, | ||
| 337 | result, | 349 | result, |
| 338 | startedAtMs: yield* Clock.currentTimeMillis, | 350 | startedAtMs: yield* Clock.currentTimeMillis, |
| 339 | }); | 351 | }); |
apps/server/src/orchestration/Services/OrchestrationEngine.ts+8-1| ... | @@ -10,7 +10,11 @@ | ... | @@ -10,7 +10,11 @@ |
| 10 | * | 10 | * |
| 11 | * @module OrchestrationEngineService | 11 | * @module OrchestrationEngineService |
| 12 | */ | 12 | */ |
| 13 | import type { OrchestrationCommand, OrchestrationEvent } from "@t3tools/contracts"; | 13 | import type { |
| 14 | OrchestrationClientOrigin, | ||
| 15 | OrchestrationCommand, | ||
| 16 | OrchestrationEvent, | ||
| 17 | } from "@t3tools/contracts"; | ||
| 14 | import * as Context from "effect/Context"; | 18 | import * as Context from "effect/Context"; |
| 15 | import type * as Effect from "effect/Effect"; | 19 | import type * as Effect from "effect/Effect"; |
| 16 | import type * as Stream from "effect/Stream"; | 20 | import type * as Stream from "effect/Stream"; |
| ... | @@ -41,6 +45,8 @@ export interface OrchestrationEngineShape { | ... | @@ -41,6 +45,8 @@ export interface OrchestrationEngineShape { |
| 41 | * Dispatch a validated orchestration command. | 45 | * Dispatch a validated orchestration command. |
| 42 | * | 46 | * |
| 43 | * @param command - Valid orchestration command. | 47 | * @param command - Valid orchestration command. |
| 48 | * @param options - Optional client origin (surface/app version) stamped into | ||
| 49 | * the metadata of every event the command produces. | ||
| 44 | * @returns Effect containing the sequence of the persisted event. | 50 | * @returns Effect containing the sequence of the persisted event. |
| 45 | * | 51 | * |
| 46 | * Dispatch is serialized through an internal queue and deduplicated via | 52 | * Dispatch is serialized through an internal queue and deduplicated via |
| ... | @@ -48,6 +54,7 @@ export interface OrchestrationEngineShape { | ... | @@ -48,6 +54,7 @@ export interface OrchestrationEngineShape { |
| 48 | */ | 54 | */ |
| 49 | readonly dispatch: ( | 55 | readonly dispatch: ( |
| 50 | command: OrchestrationCommand, | 56 | command: OrchestrationCommand, |
| 57 | options?: { readonly origin?: OrchestrationClientOrigin }, | ||
| 51 | ) => Effect.Effect<{ sequence: number }, OrchestrationDispatchError, never>; | 58 | ) => Effect.Effect<{ sequence: number }, OrchestrationDispatchError, never>; |
| 52 | 59 | ||
| 53 | /** | 60 | /** |
apps/server/src/persistence/AuthSessions.ts+37| ... | @@ -10,6 +10,7 @@ import { | ... | @@ -10,6 +10,7 @@ import { |
| 10 | AuthClientMetadataDeviceType, | 10 | AuthClientMetadataDeviceType, |
| 11 | AuthEnvironmentScopes, | 11 | AuthEnvironmentScopes, |
| 12 | AuthSessionId, | 12 | AuthSessionId, |
| 13 | ClientSurface, | ||
| 13 | ServerAuthSessionMethod, | 14 | ServerAuthSessionMethod, |
| 14 | } from "@t3tools/contracts"; | 15 | } from "@t3tools/contracts"; |
| 15 | 16 | ||
| ... | @@ -82,6 +83,13 @@ export const SetAuthSessionLastConnectedAtInput = Schema.Struct({ | ... | @@ -82,6 +83,13 @@ export const SetAuthSessionLastConnectedAtInput = Schema.Struct({ |
| 82 | }); | 83 | }); |
| 83 | export type SetAuthSessionLastConnectedAtInput = typeof SetAuthSessionLastConnectedAtInput.Type; | 84 | export type SetAuthSessionLastConnectedAtInput = typeof SetAuthSessionLastConnectedAtInput.Type; |
| 84 | 85 | ||
| 86 | export const SetAuthSessionClientConnectionInput = Schema.Struct({ | ||
| 87 | sessionId: AuthSessionId, | ||
| 88 | surface: Schema.NullOr(ClientSurface), | ||
| 89 | appVersion: Schema.NullOr(Schema.String), | ||
| 90 | }); | ||
| 91 | export type SetAuthSessionClientConnectionInput = typeof SetAuthSessionClientConnectionInput.Type; | ||
| 92 | |||
| 85 | export class AuthSessionRepository extends Context.Service< | 93 | export class AuthSessionRepository extends Context.Service< |
| 86 | AuthSessionRepository, | 94 | AuthSessionRepository, |
| 87 | { | 95 | { |
| ... | @@ -103,6 +111,9 @@ export class AuthSessionRepository extends Context.Service< | ... | @@ -103,6 +111,9 @@ export class AuthSessionRepository extends Context.Service< |
| 103 | readonly setLastConnectedAt: ( | 111 | readonly setLastConnectedAt: ( |
| 104 | input: SetAuthSessionLastConnectedAtInput, | 112 | input: SetAuthSessionLastConnectedAtInput, |
| 105 | ) => Effect.Effect<void, AuthSessionRepositoryError>; | 113 | ) => Effect.Effect<void, AuthSessionRepositoryError>; |
| 114 | readonly setClientConnection: ( | ||
| 115 | input: SetAuthSessionClientConnectionInput, | ||
| 116 | ) => Effect.Effect<void, AuthSessionRepositoryError>; | ||
| 106 | } | 117 | } |
| 107 | >()("t3/persistence/AuthSessions/AuthSessionRepository") {} | 118 | >()("t3/persistence/AuthSessions/AuthSessionRepository") {} |
| 108 | 119 | ||
| ... | @@ -281,6 +292,20 @@ export const make = Effect.gen(function* () { | ... | @@ -281,6 +292,20 @@ export const make = Effect.gen(function* () { |
| 281 | `, | 292 | `, |
| 282 | }); | 293 | }); |
| 283 | 294 | ||
| 295 | // COALESCE keeps the previous value when a client reports only one field, so | ||
| 296 | // a partial report never nulls out data a fuller client stored earlier. | ||
| 297 | const setClientConnectionRow = SqlSchema.void({ | ||
| 298 | Request: SetAuthSessionClientConnectionInput, | ||
| 299 | execute: ({ sessionId, surface, appVersion }) => | ||
| 300 | sql` | ||
| 301 | UPDATE auth_sessions | ||
| 302 | SET client_surface = COALESCE(${surface}, client_surface), | ||
| 303 | client_app_version = COALESCE(${appVersion}, client_app_version) | ||
| 304 | WHERE session_id = ${sessionId} | ||
| 305 | AND revoked_at IS NULL | ||
| 306 | `, | ||
| 307 | }); | ||
| 308 | |||
| 284 | const revokeSessionRows = SqlSchema.findAll({ | 309 | const revokeSessionRows = SqlSchema.findAll({ |
| 285 | Request: RevokeAuthSessionInput, | 310 | Request: RevokeAuthSessionInput, |
| 286 | Result: Schema.Struct({ sessionId: AuthSessionId }), | 311 | Result: Schema.Struct({ sessionId: AuthSessionId }), |
| ... | @@ -404,6 +429,17 @@ export const make = Effect.gen(function* () { | ... | @@ -404,6 +429,17 @@ export const make = Effect.gen(function* () { |
| 404 | ), | 429 | ), |
| 405 | ); | 430 | ); |
| 406 | 431 | ||
| 432 | const setClientConnection: AuthSessionRepository["Service"]["setClientConnection"] = (input) => | ||
| 433 | setClientConnectionRow(input).pipe( | ||
| 434 | Effect.mapError( | ||
| 435 | toPersistenceSqlOrDecodeError( | ||
| 436 | "AuthSessionRepository.setClientConnection:query", | ||
| 437 | "AuthSessionRepository.setClientConnection:encodeRequest", | ||
| 438 | { sessionId: input.sessionId }, | ||
| 439 | ), | ||
| 440 | ), | ||
| 441 | ); | ||
| 442 | |||
| 407 | return { | 443 | return { |
| 408 | create, | 444 | create, |
| 409 | getById, | 445 | getById, |
| ... | @@ -411,6 +447,7 @@ export const make = Effect.gen(function* () { | ... | @@ -411,6 +447,7 @@ export const make = Effect.gen(function* () { |
| 411 | revoke, | 447 | revoke, |
| 412 | revokeAllExcept, | 448 | revokeAllExcept, |
| 413 | setLastConnectedAt, | 449 | setLastConnectedAt, |
| 450 | setClientConnection, | ||
| 414 | } satisfies AuthSessionRepository["Service"]; | 451 | } satisfies AuthSessionRepository["Service"]; |
| 415 | }); | 452 | }); |
| 416 | 453 |
apps/server/src/persistence/Migrations.ts+2| ... | @@ -53,6 +53,7 @@ import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; | ... | @@ -53,6 +53,7 @@ import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; |
| 53 | import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; | 53 | import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; |
| 54 | import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; | 54 | import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; |
| 55 | import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; | 55 | import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; |
| 56 | import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts"; | ||
| 56 | 57 | ||
| 57 | /** | 58 | /** |
| 58 | * Migration loader with all migrations defined inline. | 59 | * Migration loader with all migrations defined inline. |
| ... | @@ -105,6 +106,7 @@ export const migrationEntries = [ | ... | @@ -105,6 +106,7 @@ export const migrationEntries = [ |
| 105 | [38, "ProjectionThreadsPinOrderKey", Migration0038], | 106 | [38, "ProjectionThreadsPinOrderKey", Migration0038], |
| 106 | [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], | 107 | [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], |
| 107 | [40, "ProjectionProjectFaviconPath", Migration0040], | 108 | [40, "ProjectionProjectFaviconPath", Migration0040], |
| 109 | [41, "AuthSessionClientConnection", Migration0041], | ||
| 108 | ] as const; | 110 | ] as const; |
| 109 | 111 | ||
| 110 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); | 112 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); |
apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts created+31| ... | @@ -0,0 +1,31 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 5 | |||
| 6 | import { runMigrations } from "../Migrations.ts"; | ||
| 7 | import * as NodeSqliteClient from "../NodeSqliteClient.ts"; | ||
| 8 | |||
| 9 | const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); | ||
| 10 | |||
| 11 | layer("041_AuthSessionClientConnection", (it) => { | ||
| 12 | it.effect("adds nullable client surface and app version columns to auth sessions", () => | ||
| 13 | Effect.gen(function* () { | ||
| 14 | const sql = yield* SqlClient.SqlClient; | ||
| 15 | |||
| 16 | yield* runMigrations({ toMigrationInclusive: 40 }); | ||
| 17 | yield* runMigrations({ toMigrationInclusive: 41 }); | ||
| 18 | |||
| 19 | const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` | ||
| 20 | PRAGMA table_info(auth_sessions) | ||
| 21 | `; | ||
| 22 | const surface = columns.find((column) => column.name === "client_surface"); | ||
| 23 | const appVersion = columns.find((column) => column.name === "client_app_version"); | ||
| 24 | |||
| 25 | assert.equal(surface?.name, "client_surface"); | ||
| 26 | assert.equal(surface?.notnull, 0); | ||
| 27 | assert.equal(appVersion?.name, "client_app_version"); | ||
| 28 | assert.equal(appVersion?.notnull, 0); | ||
| 29 | }), | ||
| 30 | ); | ||
| 31 | }); | ||
apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.ts created+26| ... | @@ -0,0 +1,26 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 3 | |||
| 4 | // Client-declared surface (web/desktop/mobile) and app version, refreshed on | ||
| 5 | // every WebSocket connect so the row tracks the client's current build instead | ||
| 6 | // of freezing at session issuance. Nullable: old clients never report them. | ||
| 7 | export default Effect.gen(function* () { | ||
| 8 | const sql = yield* SqlClient.SqlClient; | ||
| 9 | const columns = yield* sql<{ readonly name: string }>` | ||
| 10 | PRAGMA table_info(auth_sessions) | ||
| 11 | `; | ||
| 12 | |||
| 13 | if (!columns.some((column) => column.name === "client_surface")) { | ||
| 14 | yield* sql` | ||
| 15 | ALTER TABLE auth_sessions | ||
| 16 | ADD COLUMN client_surface TEXT | ||
| 17 | `; | ||
| 18 | } | ||
| 19 | |||
| 20 | if (!columns.some((column) => column.name === "client_app_version")) { | ||
| 21 | yield* sql` | ||
| 22 | ALTER TABLE auth_sessions | ||
| 23 | ADD COLUMN client_app_version TEXT | ||
| 24 | `; | ||
| 25 | } | ||
| 26 | }); | ||
apps/server/src/server.test.ts+96| ... | @@ -151,6 +151,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien | ... | @@ -151,6 +151,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien |
| 151 | import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; | 151 | import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; |
| 152 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; | 152 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; |
| 153 | import * as UsageService from "./usage/UsageService.ts"; | 153 | import * as UsageService from "./usage/UsageService.ts"; |
| 154 | import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; | ||
| 154 | import * as Data from "effect/Data"; | 155 | import * as Data from "effect/Data"; |
| 155 | 156 | ||
| 156 | import { makeOrchestrationIntegrationHarness } from "../integration/OrchestrationEngineHarness.integration.ts"; | 157 | import { makeOrchestrationIntegrationHarness } from "../integration/OrchestrationEngineHarness.integration.ts"; |
| ... | @@ -402,6 +403,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -402,6 +403,7 @@ const buildAppUnderTest = (options?: { |
| 402 | >; | 403 | >; |
| 403 | terminalManager?: Partial<TerminalManager.TerminalManager["Service"]>; | 404 | terminalManager?: Partial<TerminalManager.TerminalManager["Service"]>; |
| 404 | orchestrationEngine?: Partial<OrchestrationEngine.OrchestrationEngineService["Service"]>; | 405 | orchestrationEngine?: Partial<OrchestrationEngine.OrchestrationEngineService["Service"]>; |
| 406 | analyticsService?: Partial<AnalyticsService.AnalyticsService["Service"]>; | ||
| 405 | projectionSnapshotQuery?: Partial<ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]>; | 407 | projectionSnapshotQuery?: Partial<ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]>; |
| 406 | checkpointDiffQuery?: Partial<CheckpointDiffQuery.CheckpointDiffQuery["Service"]>; | 408 | checkpointDiffQuery?: Partial<CheckpointDiffQuery.CheckpointDiffQuery["Service"]>; |
| 407 | browserTraceCollector?: Partial<BrowserTraceCollector.BrowserTraceCollector["Service"]>; | 409 | browserTraceCollector?: Partial<BrowserTraceCollector.BrowserTraceCollector["Service"]>; |
| ... | @@ -833,6 +835,13 @@ const buildAppUnderTest = (options?: { | ... | @@ -833,6 +835,13 @@ const buildAppUnderTest = (options?: { |
| 833 | const appLayer = servedRoutesLayer.pipe( | 835 | const appLayer = servedRoutesLayer.pipe( |
| 834 | Layer.provide(resourceTelemetryLayer), | 836 | Layer.provide(resourceTelemetryLayer), |
| 835 | Layer.provide(UsageService.layerTest), | 837 | Layer.provide(UsageService.layerTest), |
| 838 | Layer.provide( | ||
| 839 | Layer.mock(AnalyticsService.AnalyticsService)({ | ||
| 840 | record: () => Effect.void, | ||
| 841 | flush: Effect.void, | ||
| 842 | ...options?.layers?.analyticsService, | ||
| 843 | }), | ||
| 844 | ), | ||
| 836 | Layer.provide( | 845 | Layer.provide( |
| 837 | Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ | 846 | Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ |
| 838 | record: () => Effect.void, | 847 | record: () => Effect.void, |
| ... | @@ -5035,6 +5044,93 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -5035,6 +5044,93 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 5035 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 5044 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 5036 | ); | 5045 | ); |
| 5037 | 5046 | ||
| 5047 | it.effect("records thread analytics only after a client command succeeds", () => | ||
| 5048 | Effect.gen(function* () { | ||
| 5049 | const effects: string[] = []; | ||
| 5050 | const analyticsProperties: Array<Readonly<Record<string, unknown>> | undefined> = []; | ||
| 5051 | const failedCommandId = CommandId.make("cmd-thread-create-failed"); | ||
| 5052 | |||
| 5053 | yield* buildAppUnderTest({ | ||
| 5054 | layers: { | ||
| 5055 | analyticsService: { | ||
| 5056 | record: (event, properties) => | ||
| 5057 | Effect.sync(() => { | ||
| 5058 | effects.push(`analytics:${event}`); | ||
| 5059 | analyticsProperties.push(properties); | ||
| 5060 | }), | ||
| 5061 | }, | ||
| 5062 | orchestrationEngine: { | ||
| 5063 | dispatch: (command) => | ||
| 5064 | Effect.sync(() => effects.push(`dispatch:${command.commandId}`)).pipe( | ||
| 5065 | Effect.flatMap(() => | ||
| 5066 | command.commandId === failedCommandId | ||
| 5067 | ? Effect.fail( | ||
| 5068 | new OrchestrationListenerCallbackError({ | ||
| 5069 | listener: "domain-event", | ||
| 5070 | detail: "thread creation failed", | ||
| 5071 | }), | ||
| 5072 | ) | ||
| 5073 | : Effect.succeed({ sequence: 1 }), | ||
| 5074 | ), | ||
| 5075 | ), | ||
| 5076 | }, | ||
| 5077 | }, | ||
| 5078 | }); | ||
| 5079 | |||
| 5080 | const createThreadCommand = (commandId: CommandId, threadId: ThreadId) => | ||
| 5081 | ({ | ||
| 5082 | type: "thread.create", | ||
| 5083 | commandId, | ||
| 5084 | threadId, | ||
| 5085 | projectId: defaultProjectId, | ||
| 5086 | title: "Analytics test", | ||
| 5087 | modelSelection: defaultModelSelection, | ||
| 5088 | runtimeMode: "full-access", | ||
| 5089 | interactionMode: "default", | ||
| 5090 | branch: null, | ||
| 5091 | worktreePath: null, | ||
| 5092 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 5093 | }) as const; | ||
| 5094 | |||
| 5095 | const wsUrl = yield* getWsServerUrl("/ws?clientSurface=mobile&clientAppVersion=1.2.3"); | ||
| 5096 | yield* Effect.scoped( | ||
| 5097 | withWsRpcClient(wsUrl, (client) => | ||
| 5098 | Effect.gen(function* () { | ||
| 5099 | const failed = yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]( | ||
| 5100 | createThreadCommand(failedCommandId, ThreadId.make("thread-create-failed")), | ||
| 5101 | ).pipe(Effect.result); | ||
| 5102 | |||
| 5103 | assert.equal(failed._tag, "Failure"); | ||
| 5104 | assert.deepEqual(effects, [ | ||
| 5105 | "analytics:client.connected", | ||
| 5106 | "dispatch:cmd-thread-create-failed", | ||
| 5107 | ]); | ||
| 5108 | |||
| 5109 | const succeeded = yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]( | ||
| 5110 | createThreadCommand( | ||
| 5111 | CommandId.make("cmd-thread-create-succeeded"), | ||
| 5112 | ThreadId.make("thread-create-succeeded"), | ||
| 5113 | ), | ||
| 5114 | ); | ||
| 5115 | |||
| 5116 | assert.equal(succeeded.sequence, 1); | ||
| 5117 | }), | ||
| 5118 | ), | ||
| 5119 | ); | ||
| 5120 | |||
| 5121 | assert.deepEqual(effects, [ | ||
| 5122 | "analytics:client.connected", | ||
| 5123 | "dispatch:cmd-thread-create-failed", | ||
| 5124 | "dispatch:cmd-thread-create-succeeded", | ||
| 5125 | "analytics:client.thread.started", | ||
| 5126 | ]); | ||
| 5127 | assert.deepEqual(analyticsProperties, [ | ||
| 5128 | { surface: "mobile", appVersion: "1.2.3" }, | ||
| 5129 | { surface: "mobile", appVersion: "1.2.3" }, | ||
| 5130 | ]); | ||
| 5131 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 5132 | ); | ||
| 5133 | |||
| 5038 | it.effect("routes websocket rpc projects.writeFile errors", () => | 5134 | it.effect("routes websocket rpc projects.writeFile errors", () => |
| 5039 | Effect.gen(function* () { | 5135 | Effect.gen(function* () { |
| 5040 | const fs = yield* FileSystem.FileSystem; | 5136 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/ws.ts+77-13| ... | @@ -15,9 +15,11 @@ import { | ... | @@ -15,9 +15,11 @@ import { |
| 15 | type AuthAccessStreamEvent, | 15 | type AuthAccessStreamEvent, |
| 16 | type AuthEnvironmentScope, | 16 | type AuthEnvironmentScope, |
| 17 | AuthSessionId, | 17 | AuthSessionId, |
| 18 | ClientSurface, | ||
| 18 | CommandId, | 19 | CommandId, |
| 19 | type DiscoveredLocalServerList, | 20 | type DiscoveredLocalServerList, |
| 20 | EventId, | 21 | EventId, |
| 22 | type OrchestrationClientOrigin, | ||
| 21 | type OrchestrationCommand, | 23 | type OrchestrationCommand, |
| 22 | type GitActionProgressEvent, | 24 | type GitActionProgressEvent, |
| 23 | type GitManagerServiceError, | 25 | type GitManagerServiceError, |
| ... | @@ -106,6 +108,7 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; | ... | @@ -106,6 +108,7 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; |
| 106 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; | 108 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 107 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; | 109 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 108 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; | 110 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; |
| 111 | import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; | ||
| 109 | import * as UsageService from "./usage/UsageService.ts"; | 112 | import * as UsageService from "./usage/UsageService.ts"; |
| 110 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; | 113 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| 111 | import * as PullRequestService from "./pullRequest/PullRequestService.ts"; | 114 | import * as PullRequestService from "./pullRequest/PullRequestService.ts"; |
| ... | @@ -349,8 +352,37 @@ function toAuthAccessStreamEvent( | ... | @@ -349,8 +352,37 @@ function toAuthAccessStreamEvent( |
| 349 | } | 352 | } |
| 350 | } | 353 | } |
| 351 | 354 | ||
| 355 | const isClientSurface = Schema.is(ClientSurface); | ||
| 356 | const MAX_CLIENT_APP_VERSION_LENGTH = 64; | ||
| 357 | |||
| 358 | // Optional client identity announced on the /ws upgrade URL next to wsTicket. | ||
| 359 | // Lenient by design: absent or malformed values degrade to {} so a connection | ||
| 360 | // never fails over attribution metadata. | ||
| 361 | function readClientConnectionOrigin( | ||
| 362 | request: HttpServerRequest.HttpServerRequest, | ||
| 363 | ): OrchestrationClientOrigin { | ||
| 364 | const url = HttpServerRequest.toURL(request); | ||
| 365 | if (Option.isNone(url)) { | ||
| 366 | return {}; | ||
| 367 | } | ||
| 368 | const surface = url.value.searchParams.get("clientSurface"); | ||
| 369 | const appVersion = url.value.searchParams.get("clientAppVersion")?.trim() ?? ""; | ||
| 370 | return { | ||
| 371 | ...(isClientSurface(surface) ? { surface } : {}), | ||
| 372 | ...(appVersion !== "" && appVersion.length <= MAX_CLIENT_APP_VERSION_LENGTH | ||
| 373 | ? { appVersion } | ||
| 374 | : {}), | ||
| 375 | }; | ||
| 376 | } | ||
| 377 | |||
| 378 | const clientOriginAnalyticsProps = (origin: OrchestrationClientOrigin) => ({ | ||
| 379 | ...(origin.surface !== undefined ? { surface: origin.surface } : {}), | ||
| 380 | ...(origin.appVersion !== undefined ? { appVersion: origin.appVersion } : {}), | ||
| 381 | }); | ||
| 382 | |||
| 352 | const makeWsRpcLayer = ( | 383 | const makeWsRpcLayer = ( |
| 353 | currentSession: EnvironmentAuth.AuthenticatedSession, | 384 | currentSession: EnvironmentAuth.AuthenticatedSession, |
| 385 | clientOrigin: OrchestrationClientOrigin, | ||
| 354 | previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], | 386 | previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], |
| 355 | ) => | 387 | ) => |
| 356 | WsRpcGroup.toLayer( | 388 | WsRpcGroup.toLayer( |
| ... | @@ -359,6 +391,35 @@ const makeWsRpcLayer = ( | ... | @@ -359,6 +391,35 @@ const makeWsRpcLayer = ( |
| 359 | const crypto = yield* Crypto.Crypto; | 391 | const crypto = yield* Crypto.Crypto; |
| 360 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; | 392 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; |
| 361 | const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; | 393 | const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; |
| 394 | const analytics = yield* AnalyticsService.AnalyticsService; | ||
| 395 | // Every command dispatched on this connection carries the connecting | ||
| 396 | // client's origin, including server-generated bootstrap sub-commands: | ||
| 397 | // the client's request caused them. | ||
| 398 | const hasClientOrigin = | ||
| 399 | clientOrigin.surface !== undefined || clientOrigin.appVersion !== undefined; | ||
| 400 | const dispatchFromClient: OrchestrationEngine.OrchestrationEngineShape["dispatch"] = ( | ||
| 401 | command, | ||
| 402 | ) => | ||
| 403 | orchestrationEngine.dispatch( | ||
| 404 | command, | ||
| 405 | hasClientOrigin ? { origin: clientOrigin } : undefined, | ||
| 406 | ); | ||
| 407 | const originProps = clientOriginAnalyticsProps(clientOrigin); | ||
| 408 | const recordClientCommandAnalytics = (command: OrchestrationCommand) => { | ||
| 409 | switch (command.type) { | ||
| 410 | case "thread.create": | ||
| 411 | return analytics.record("client.thread.started", originProps); | ||
| 412 | case "thread.turn.start": | ||
| 413 | return command.bootstrap?.createThread | ||
| 414 | ? Effect.andThen( | ||
| 415 | analytics.record("client.thread.started", originProps), | ||
| 416 | analytics.record("client.turn.requested", originProps), | ||
| 417 | ) | ||
| 418 | : analytics.record("client.turn.requested", originProps); | ||
| 419 | default: | ||
| 420 | return Effect.void; | ||
| 421 | } | ||
| 422 | }; | ||
| 362 | const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; | 423 | const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; |
| 363 | const keybindings = yield* Keybindings.Keybindings; | 424 | const keybindings = yield* Keybindings.Keybindings; |
| 364 | const externalLauncher = yield* ExternalLauncher.ExternalLauncher; | 425 | const externalLauncher = yield* ExternalLauncher.ExternalLauncher; |
| ... | @@ -514,7 +575,7 @@ const makeWsRpcLayer = ( | ... | @@ -514,7 +575,7 @@ const makeWsRpcLayer = ( |
| 514 | activityId: serverEventId, | 575 | activityId: serverEventId, |
| 515 | }).pipe( | 576 | }).pipe( |
| 516 | Effect.flatMap(({ commandId, activityId }) => | 577 | Effect.flatMap(({ commandId, activityId }) => |
| 517 | orchestrationEngine.dispatch({ | 578 | dispatchFromClient({ |
| 518 | type: "thread.activity.append", | 579 | type: "thread.activity.append", |
| 519 | commandId, | 580 | commandId, |
| 520 | threadId: input.threadId, | 581 | threadId: input.threadId, |
| ... | @@ -769,7 +830,7 @@ const makeWsRpcLayer = ( | ... | @@ -769,7 +830,7 @@ const makeWsRpcLayer = ( |
| 769 | createdThread | 830 | createdThread |
| 770 | ? serverCommandId("bootstrap-thread-delete").pipe( | 831 | ? serverCommandId("bootstrap-thread-delete").pipe( |
| 771 | Effect.flatMap((commandId) => | 832 | Effect.flatMap((commandId) => |
| 772 | orchestrationEngine.dispatch({ | 833 | dispatchFromClient({ |
| 773 | type: "thread.delete", | 834 | type: "thread.delete", |
| 774 | commandId, | 835 | commandId, |
| 775 | threadId: command.threadId, | 836 | threadId: command.threadId, |
| ... | @@ -896,7 +957,7 @@ const makeWsRpcLayer = ( | ... | @@ -896,7 +957,7 @@ const makeWsRpcLayer = ( |
| 896 | 957 | ||
| 897 | const bootstrapProgram = Effect.gen(function* () { | 958 | const bootstrapProgram = Effect.gen(function* () { |
| 898 | if (bootstrap?.createThread) { | 959 | if (bootstrap?.createThread) { |
| 899 | yield* orchestrationEngine.dispatch({ | 960 | yield* dispatchFromClient({ |
| 900 | type: "thread.create", | 961 | type: "thread.create", |
| 901 | commandId: yield* serverCommandId("bootstrap-thread-create"), | 962 | commandId: yield* serverCommandId("bootstrap-thread-create"), |
| 902 | threadId: command.threadId, | 963 | threadId: command.threadId, |
| ... | @@ -943,7 +1004,7 @@ const makeWsRpcLayer = ( | ... | @@ -943,7 +1004,7 @@ const makeWsRpcLayer = ( |
| 943 | path: null, | 1004 | path: null, |
| 944 | }); | 1005 | }); |
| 945 | targetWorktreePath = worktree.worktree.path; | 1006 | targetWorktreePath = worktree.worktree.path; |
| 946 | yield* orchestrationEngine.dispatch({ | 1007 | yield* dispatchFromClient({ |
| 947 | type: "thread.meta.update", | 1008 | type: "thread.meta.update", |
| 948 | commandId: yield* serverCommandId("bootstrap-thread-meta-update"), | 1009 | commandId: yield* serverCommandId("bootstrap-thread-meta-update"), |
| 949 | threadId: command.threadId, | 1010 | threadId: command.threadId, |
| ... | @@ -955,7 +1016,7 @@ const makeWsRpcLayer = ( | ... | @@ -955,7 +1016,7 @@ const makeWsRpcLayer = ( |
| 955 | 1016 | ||
| 956 | yield* runSetupProgram(); | 1017 | yield* runSetupProgram(); |
| 957 | 1018 | ||
| 958 | return yield* orchestrationEngine.dispatch(finalTurnStartCommand); | 1019 | return yield* dispatchFromClient(finalTurnStartCommand); |
| 959 | }); | 1020 | }); |
| 960 | 1021 | ||
| 961 | return yield* bootstrapProgram.pipe( | 1022 | return yield* bootstrapProgram.pipe( |
| ... | @@ -995,13 +1056,11 @@ const makeWsRpcLayer = ( | ... | @@ -995,13 +1056,11 @@ const makeWsRpcLayer = ( |
| 995 | const dispatchEffect = | 1056 | const dispatchEffect = |
| 996 | normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap | 1057 | normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap |
| 997 | ? dispatchBootstrapTurnStart(normalizedCommand) | 1058 | ? dispatchBootstrapTurnStart(normalizedCommand) |
| 998 | : orchestrationEngine | 1059 | : dispatchFromClient(normalizedCommand).pipe( |
| 999 | .dispatch(normalizedCommand) | 1060 | Effect.mapError((cause) => |
| 1000 | .pipe( | 1061 | toDispatchCommandError(cause, "Failed to dispatch orchestration command"), |
| 1001 | Effect.mapError((cause) => | 1062 | ), |
| 1002 | toDispatchCommandError(cause, "Failed to dispatch orchestration command"), | 1063 | ); |
| 1003 | ), | ||
| 1004 | ); | ||
| 1005 | 1064 | ||
| 1006 | return startup | 1065 | return startup |
| 1007 | .enqueueCommand(dispatchEffect) | 1066 | .enqueueCommand(dispatchEffect) |
| ... | @@ -1098,6 +1157,7 @@ const makeWsRpcLayer = ( | ... | @@ -1098,6 +1157,7 @@ const makeWsRpcLayer = ( |
| 1098 | ) | 1157 | ) |
| 1099 | : false; | 1158 | : false; |
| 1100 | const result = yield* dispatchNormalizedCommand(normalizedCommand); | 1159 | const result = yield* dispatchNormalizedCommand(normalizedCommand); |
| 1160 | yield* recordClientCommandAnalytics(normalizedCommand); | ||
| 1101 | if (parkingCommand) { | 1161 | if (parkingCommand) { |
| 1102 | const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle"; | 1162 | const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle"; |
| 1103 | if (shouldStopSessionAfterCommand) { | 1163 | if (shouldStopSessionAfterCommand) { |
| ... | @@ -2330,6 +2390,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( | ... | @@ -2330,6 +2390,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 2330 | const request = yield* HttpServerRequest.HttpServerRequest; | 2390 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 2331 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | 2391 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 2332 | const sessions = yield* SessionStore.SessionStore; | 2392 | const sessions = yield* SessionStore.SessionStore; |
| 2393 | const analytics = yield* AnalyticsService.AnalyticsService; | ||
| 2333 | const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( | 2394 | const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( |
| 2334 | Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => | 2395 | Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => |
| 2335 | failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), | 2396 | failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), |
| ... | @@ -2338,11 +2399,14 @@ export const websocketRpcRouteLayer = Layer.unwrap( | ... | @@ -2338,11 +2399,14 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 2338 | failEnvironmentInternal("internal_error", error), | 2399 | failEnvironmentInternal("internal_error", error), |
| 2339 | ), | 2400 | ), |
| 2340 | ); | 2401 | ); |
| 2402 | const clientOrigin = readClientConnectionOrigin(request); | ||
| 2403 | yield* sessions.recordClientConnection(session.sessionId, clientOrigin); | ||
| 2404 | yield* analytics.record("client.connected", clientOriginAnalyticsProps(clientOrigin)); | ||
| 2341 | const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { | 2405 | const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { |
| 2342 | disableTracing: true, | 2406 | disableTracing: true, |
| 2343 | }).pipe( | 2407 | }).pipe( |
| 2344 | Effect.provide( | 2408 | Effect.provide( |
| 2345 | makeWsRpcLayer(session, previewAutomationBroker).pipe( | 2409 | makeWsRpcLayer(session, clientOrigin, previewAutomationBroker).pipe( |
| 2346 | Layer.provideMerge(RpcSerialization.layerJson), | 2410 | Layer.provideMerge(RpcSerialization.layerJson), |
| 2347 | Layer.provide(ProviderMaintenanceRunner.layer), | 2411 | Layer.provide(ProviderMaintenanceRunner.layer), |
| 2348 | Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), | 2412 | Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), |
apps/web/src/connection/platform.ts+3| ... | @@ -42,6 +42,7 @@ import * as Ref from "effect/Ref"; | ... | @@ -42,6 +42,7 @@ import * as Ref from "effect/Ref"; |
| 42 | import * as Stream from "effect/Stream"; | 42 | import * as Stream from "effect/Stream"; |
| 43 | import { FetchHttpClient } from "effect/unstable/http"; | 43 | import { FetchHttpClient } from "effect/unstable/http"; |
| 44 | 44 | ||
| 45 | import { APP_VERSION } from "../branding"; | ||
| 45 | import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth"; | 46 | import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth"; |
| 46 | import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; | 47 | import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; |
| 47 | import { | 48 | import { |
| ... | @@ -120,6 +121,8 @@ function clientMetadata() { | ... | @@ -120,6 +121,8 @@ function clientMetadata() { |
| 120 | label: desktop ? "T3 Code Desktop" : "T3 Code Web", | 121 | label: desktop ? "T3 Code Desktop" : "T3 Code Web", |
| 121 | deviceType: "desktop" as const, | 122 | deviceType: "desktop" as const, |
| 122 | ...(platform === "" ? {} : { os: platform }), | 123 | ...(platform === "" ? {} : { os: platform }), |
| 124 | surface: desktop ? ("desktop" as const) : ("web" as const), | ||
| 125 | ...(APP_VERSION === "0.0.0" ? {} : { appVersion: APP_VERSION }), | ||
| 123 | }; | 126 | }; |
| 124 | } | 127 | } |
| 125 | 128 |
packages/client-runtime/src/authorization/remote.ts+18| ... | @@ -32,6 +32,20 @@ const clientMetadataTokenExchangeFields = ( | ... | @@ -32,6 +32,20 @@ const clientMetadataTokenExchangeFields = ( |
| 32 | ...(clientMetadata?.os ? { client_os: clientMetadata.os } : {}), | 32 | ...(clientMetadata?.os ? { client_os: clientMetadata.os } : {}), |
| 33 | }); | 33 | }); |
| 34 | 34 | ||
| 35 | // The server reads these off the /ws upgrade URL next to wsTicket. Optional on | ||
| 36 | // both ends: old servers ignore unknown params, old clients never send them. | ||
| 37 | export const appendClientConnectionParams = ( | ||
| 38 | url: URL, | ||
| 39 | clientMetadata: AuthClientPresentationMetadata | undefined, | ||
| 40 | ): void => { | ||
| 41 | if (clientMetadata?.surface) { | ||
| 42 | url.searchParams.set("clientSurface", clientMetadata.surface); | ||
| 43 | } | ||
| 44 | if (clientMetadata?.appVersion) { | ||
| 45 | url.searchParams.set("clientAppVersion", clientMetadata.appVersion); | ||
| 46 | } | ||
| 47 | }; | ||
| 48 | |||
| 35 | export const exchangeRemoteDpopAccessToken = Effect.fn( | 49 | export const exchangeRemoteDpopAccessToken = Effect.fn( |
| 36 | "clientRuntime.authorization.exchangeRemoteDpopAccessToken", | 50 | "clientRuntime.authorization.exchangeRemoteDpopAccessToken", |
| 37 | )(function* (input: { | 51 | )(function* (input: { |
| ... | @@ -174,6 +188,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( | ... | @@ -174,6 +188,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( |
| 174 | readonly wsBaseUrl: string; | 188 | readonly wsBaseUrl: string; |
| 175 | readonly httpBaseUrl: string; | 189 | readonly httpBaseUrl: string; |
| 176 | readonly bearerToken: string; | 190 | readonly bearerToken: string; |
| 191 | readonly clientMetadata?: AuthClientPresentationMetadata; | ||
| 177 | readonly timeoutMs?: number; | 192 | readonly timeoutMs?: number; |
| 178 | }) { | 193 | }) { |
| 179 | const issued = yield* issueRemoteWebSocketTicket({ | 194 | const issued = yield* issueRemoteWebSocketTicket({ |
| ... | @@ -187,6 +202,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( | ... | @@ -187,6 +202,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( |
| 187 | url.pathname = "/ws"; | 202 | url.pathname = "/ws"; |
| 188 | } | 203 | } |
| 189 | url.searchParams.set("wsTicket", issued.ticket); | 204 | url.searchParams.set("wsTicket", issued.ticket); |
| 205 | appendClientConnectionParams(url, input.clientMetadata); | ||
| 190 | return url.toString(); | 206 | return url.toString(); |
| 191 | }); | 207 | }); |
| 192 | 208 | ||
| ... | @@ -197,6 +213,7 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( | ... | @@ -197,6 +213,7 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( |
| 197 | readonly httpBaseUrl: string; | 213 | readonly httpBaseUrl: string; |
| 198 | readonly accessToken: string; | 214 | readonly accessToken: string; |
| 199 | readonly dpopProof: string; | 215 | readonly dpopProof: string; |
| 216 | readonly clientMetadata?: AuthClientPresentationMetadata; | ||
| 200 | readonly timeoutMs?: number; | 217 | readonly timeoutMs?: number; |
| 201 | }) { | 218 | }) { |
| 202 | const issued = yield* issueRemoteDpopWebSocketTicket({ | 219 | const issued = yield* issueRemoteDpopWebSocketTicket({ |
| ... | @@ -210,5 +227,6 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( | ... | @@ -210,5 +227,6 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( |
| 210 | url.pathname = "/ws"; | 227 | url.pathname = "/ws"; |
| 211 | } | 228 | } |
| 212 | url.searchParams.set("wsTicket", issued.ticket); | 229 | url.searchParams.set("wsTicket", issued.ticket); |
| 230 | appendClientConnectionParams(url, input.clientMetadata); | ||
| 213 | return url.toString(); | 231 | return url.toString(); |
| 214 | }); | 232 | }); |
packages/client-runtime/src/authorization/service.ts+2| ... | @@ -131,6 +131,7 @@ export const make = Effect.gen(function* () { | ... | @@ -131,6 +131,7 @@ export const make = Effect.gen(function* () { |
| 131 | wsBaseUrl: input.wsBaseUrl, | 131 | wsBaseUrl: input.wsBaseUrl, |
| 132 | httpBaseUrl: input.httpBaseUrl, | 132 | httpBaseUrl: input.httpBaseUrl, |
| 133 | bearerToken: input.bearerToken, | 133 | bearerToken: input.bearerToken, |
| 134 | clientMetadata: presentation.metadata, | ||
| 134 | }).pipe( | 135 | }).pipe( |
| 135 | Effect.mapError(mapRemoteEnvironmentError), | 136 | Effect.mapError(mapRemoteEnvironmentError), |
| 136 | Effect.provideService(HttpClient.HttpClient, httpClient), | 137 | Effect.provideService(HttpClient.HttpClient, httpClient), |
| ... | @@ -170,6 +171,7 @@ export const make = Effect.gen(function* () { | ... | @@ -170,6 +171,7 @@ export const make = Effect.gen(function* () { |
| 170 | httpBaseUrl: token.endpoint.httpBaseUrl, | 171 | httpBaseUrl: token.endpoint.httpBaseUrl, |
| 171 | accessToken: token.accessToken, | 172 | accessToken: token.accessToken, |
| 172 | dpopProof: ticketProof, | 173 | dpopProof: ticketProof, |
| 174 | clientMetadata: presentation.metadata, | ||
| 173 | ...(timeoutMs === undefined ? {} : { timeoutMs }), | 175 | ...(timeoutMs === undefined ? {} : { timeoutMs }), |
| 174 | }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); | 176 | }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); |
| 175 | }, | 177 | }, |
packages/client-runtime/src/connection/resolver.test.ts+8-1| ... | @@ -174,6 +174,13 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o | ... | @@ -174,6 +174,13 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o |
| 174 | bearerToken: Effect.succeed(Option.fromNullishOr(options?.primaryBearerToken)), | 174 | bearerToken: Effect.succeed(Option.fromNullishOr(options?.primaryBearerToken)), |
| 175 | }), | 175 | }), |
| 176 | ), | 176 | ), |
| 177 | Layer.succeed( | ||
| 178 | ClientCapabilities.ClientPresentation, | ||
| 179 | ClientCapabilities.ClientPresentation.of({ | ||
| 180 | metadata: { label: "Test Client", deviceType: "desktop", surface: "web" }, | ||
| 181 | scopes: [], | ||
| 182 | }), | ||
| 183 | ), | ||
| 177 | Layer.succeed( | 184 | Layer.succeed( |
| 178 | ClientCapabilities.RelayDeviceIdentity, | 185 | ClientCapabilities.RelayDeviceIdentity, |
| 179 | ClientCapabilities.RelayDeviceIdentity.of({ | 186 | ClientCapabilities.RelayDeviceIdentity.of({ |
| ... | @@ -216,7 +223,7 @@ describe("ConnectionResolver", () => { | ... | @@ -216,7 +223,7 @@ describe("ConnectionResolver", () => { |
| 216 | environmentId: ENVIRONMENT_ID, | 223 | environmentId: ENVIRONMENT_ID, |
| 217 | label: "Primary", | 224 | label: "Primary", |
| 218 | httpBaseUrl: "http://127.0.0.1:3777", | 225 | httpBaseUrl: "http://127.0.0.1:3777", |
| 219 | socketUrl: "ws://127.0.0.1:3777/ws", | 226 | socketUrl: "ws://127.0.0.1:3777/ws?clientSurface=web", |
| 220 | httpAuthorization: null, | 227 | httpAuthorization: null, |
| 221 | target, | 228 | target, |
| 222 | }); | 229 | }); |
packages/client-runtime/src/connection/resolver.ts+9-2| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; | ||
| 1 | import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay"; | 2 | import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay"; |
| 2 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; | 3 | import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; |
| 3 | import * as Context from "effect/Context"; | 4 | import * as Context from "effect/Context"; |
| ... | @@ -6,6 +7,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -6,6 +7,7 @@ import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 7 | import * as Schema from "effect/Schema"; | 8 | import * as Schema from "effect/Schema"; |
| 8 | 9 | ||
| 10 | import { appendClientConnectionParams } from "../authorization/remote.ts"; | ||
| 9 | import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; | 11 | import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; |
| 10 | import * as ManagedRelay from "../relay/managedRelay.ts"; | 12 | import * as ManagedRelay from "../relay/managedRelay.ts"; |
| 11 | import * as ClientCapabilities from "../platform/capabilities.ts"; | 13 | import * as ClientCapabilities from "../platform/capabilities.ts"; |
| ... | @@ -46,16 +48,21 @@ const isBearerProfile = Schema.is(BearerConnectionProfile); | ... | @@ -46,16 +48,21 @@ const isBearerProfile = Schema.is(BearerConnectionProfile); |
| 46 | const isSshProfile = Schema.is(SshConnectionProfile); | 48 | const isSshProfile = Schema.is(SshConnectionProfile); |
| 47 | const isBearerCredential = Schema.is(BearerConnectionCredential); | 49 | const isBearerCredential = Schema.is(BearerConnectionCredential); |
| 48 | 50 | ||
| 49 | function primarySocketUrl(target: PrimaryConnectionTarget): string { | 51 | function primarySocketUrl( |
| 52 | target: PrimaryConnectionTarget, | ||
| 53 | clientMetadata: AuthClientPresentationMetadata | undefined, | ||
| 54 | ): string { | ||
| 50 | const url = new URL(target.wsBaseUrl); | 55 | const url = new URL(target.wsBaseUrl); |
| 51 | if (url.pathname === "" || url.pathname === "/") { | 56 | if (url.pathname === "" || url.pathname === "/") { |
| 52 | url.pathname = "/ws"; | 57 | url.pathname = "/ws"; |
| 53 | } | 58 | } |
| 59 | appendClientConnectionParams(url, clientMetadata); | ||
| 54 | return url.toString(); | 60 | return url.toString(); |
| 55 | } | 61 | } |
| 56 | 62 | ||
| 57 | const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary")(function* () { | 63 | const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary")(function* () { |
| 58 | const auth = yield* ClientCapabilities.PrimaryEnvironmentAuth; | 64 | const auth = yield* ClientCapabilities.PrimaryEnvironmentAuth; |
| 65 | const presentation = yield* ClientCapabilities.ClientPresentation; | ||
| 59 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; | 66 | const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; |
| 60 | 67 | ||
| 61 | return Effect.fn("clientRuntime.connection.broker.primary")(function* ( | 68 | return Effect.fn("clientRuntime.connection.broker.primary")(function* ( |
| ... | @@ -67,7 +74,7 @@ const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary | ... | @@ -67,7 +74,7 @@ const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary |
| 67 | environmentId: target.environmentId, | 74 | environmentId: target.environmentId, |
| 68 | label: target.label, | 75 | label: target.label, |
| 69 | httpBaseUrl: target.httpBaseUrl, | 76 | httpBaseUrl: target.httpBaseUrl, |
| 70 | socketUrl: primarySocketUrl(target), | 77 | socketUrl: primarySocketUrl(target, presentation.metadata), |
| 71 | httpAuthorization: null, | 78 | httpAuthorization: null, |
| 72 | target, | 79 | target, |
| 73 | } satisfies PreparedConnection; | 80 | } satisfies PreparedConnection; |
packages/contracts/src/auth.ts+3-1| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema"; | 2 | import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema"; |
| 3 | 3 | ||
| 4 | import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | 4 | import { AuthSessionId, ClientSurface, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 5 | 5 | ||
| 6 | /** | 6 | /** |
| 7 | * Declares the server's overall authentication posture. | 7 | * Declares the server's overall authentication posture. |
| ... | @@ -169,6 +169,8 @@ export const AuthClientPresentationMetadata = Schema.Struct({ | ... | @@ -169,6 +169,8 @@ export const AuthClientPresentationMetadata = Schema.Struct({ |
| 169 | label: Schema.optionalKey(TrimmedNonEmptyString), | 169 | label: Schema.optionalKey(TrimmedNonEmptyString), |
| 170 | deviceType: Schema.optionalKey(AuthClientMetadataDeviceType), | 170 | deviceType: Schema.optionalKey(AuthClientMetadataDeviceType), |
| 171 | os: Schema.optionalKey(TrimmedNonEmptyString), | 171 | os: Schema.optionalKey(TrimmedNonEmptyString), |
| 172 | surface: Schema.optionalKey(ClientSurface), | ||
| 173 | appVersion: Schema.optionalKey(TrimmedNonEmptyString), | ||
| 172 | }); | 174 | }); |
| 173 | export type AuthClientPresentationMetadata = typeof AuthClientPresentationMetadata.Type; | 175 | export type AuthClientPresentationMetadata = typeof AuthClientPresentationMetadata.Type; |
| 174 | 176 |
packages/contracts/src/baseSchemas.ts+9| ... | @@ -71,6 +71,15 @@ export type AuthSessionId = typeof AuthSessionId.Type; | ... | @@ -71,6 +71,15 @@ export type AuthSessionId = typeof AuthSessionId.Type; |
| 71 | export const RpcClientId = NonNegativeInt.pipe(Schema.brand("RpcClientId")); | 71 | export const RpcClientId = NonNegativeInt.pipe(Schema.brand("RpcClientId")); |
| 72 | export type RpcClientId = typeof RpcClientId.Type; | 72 | export type RpcClientId = typeof RpcClientId.Type; |
| 73 | 73 | ||
| 74 | /** | ||
| 75 | * Which client app a connection comes from. Unlike | ||
| 76 | * `AuthClientMetadataDeviceType` (a UA-style device class where web and | ||
| 77 | * desktop are both "desktop"), this names the actual product surface. | ||
| 78 | * Optional everywhere it appears: old clients never send it. | ||
| 79 | */ | ||
| 80 | export const ClientSurface = Schema.Literals(["web", "desktop", "mobile"]); | ||
| 81 | export type ClientSurface = typeof ClientSurface.Type; | ||
| 82 | |||
| 74 | export const ProviderItemId = makeEntityId("ProviderItemId"); | 83 | export const ProviderItemId = makeEntityId("ProviderItemId"); |
| 75 | export type ProviderItemId = typeof ProviderItemId.Type; | 84 | export type ProviderItemId = typeof ProviderItemId.Type; |
| 76 | export const RuntimeSessionId = makeEntityId("RuntimeSessionId"); | 85 | export const RuntimeSessionId = makeEntityId("RuntimeSessionId"); |
packages/contracts/src/orchestration.ts+14| ... | @@ -8,6 +8,7 @@ import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; | ... | @@ -8,6 +8,7 @@ import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; |
| 8 | import { | 8 | import { |
| 9 | ApprovalRequestId, | 9 | ApprovalRequestId, |
| 10 | CheckpointRef, | 10 | CheckpointRef, |
| 11 | ClientSurface, | ||
| 11 | CommandId, | 12 | CommandId, |
| 12 | EventId, | 13 | EventId, |
| 13 | IsoDateTime, | 14 | IsoDateTime, |
| ... | @@ -1319,12 +1320,25 @@ export const ThreadActivityAppendedPayload = Schema.Struct({ | ... | @@ -1319,12 +1320,25 @@ export const ThreadActivityAppendedPayload = Schema.Struct({ |
| 1319 | activity: OrchestrationThreadActivity, | 1320 | activity: OrchestrationThreadActivity, |
| 1320 | }); | 1321 | }); |
| 1321 | 1322 | ||
| 1323 | /** | ||
| 1324 | * Which client connection dispatched the command that produced an event. | ||
| 1325 | * Stamped by the orchestration engine on client-dispatched commands; absent on | ||
| 1326 | * provider/server-originated events and on commands from clients too old to | ||
| 1327 | * report it. | ||
| 1328 | */ | ||
| 1329 | export const OrchestrationClientOrigin = Schema.Struct({ | ||
| 1330 | surface: Schema.optional(ClientSurface), | ||
| 1331 | appVersion: Schema.optional(TrimmedNonEmptyString), | ||
| 1332 | }); | ||
| 1333 | export type OrchestrationClientOrigin = typeof OrchestrationClientOrigin.Type; | ||
| 1334 | |||
| 1322 | export const OrchestrationEventMetadata = Schema.Struct({ | 1335 | export const OrchestrationEventMetadata = Schema.Struct({ |
| 1323 | providerTurnId: Schema.optional(TrimmedNonEmptyString), | 1336 | providerTurnId: Schema.optional(TrimmedNonEmptyString), |
| 1324 | providerItemId: Schema.optional(ProviderItemId), | 1337 | providerItemId: Schema.optional(ProviderItemId), |
| 1325 | adapterKey: Schema.optional(TrimmedNonEmptyString), | 1338 | adapterKey: Schema.optional(TrimmedNonEmptyString), |
| 1326 | requestId: Schema.optional(ApprovalRequestId), | 1339 | requestId: Schema.optional(ApprovalRequestId), |
| 1327 | ingestedAt: Schema.optional(IsoDateTime), | 1340 | ingestedAt: Schema.optional(IsoDateTime), |
| 1341 | origin: Schema.optional(OrchestrationClientOrigin), | ||
| 1328 | }); | 1342 | }); |
| 1329 | export type OrchestrationEventMetadata = typeof OrchestrationEventMetadata.Type; | 1343 | export type OrchestrationEventMetadata = typeof OrchestrationEventMetadata.Type; |
| 1330 | 1344 |