authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-21 21:04:46-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-21 21:04:46-07:00
log11f051373e79b38fa16f3ec1af825f5164907c1b
treeb10d0efb3fa0d0fe2d85e16595f51161e4dada8c
parent035058a23e651ea5f407c9ab73cf5329cc40c102
signature Signed by PGP key B5690EEEBB952194

feat(analytics): threads and turns now know which client started them (#7774)

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";
21import * as Option from "effect/Option";21import * as Option from "effect/Option";
22import * as Queue from "effect/Queue";22import * as Queue from "effect/Queue";
23import * as Stream from "effect/Stream";23import * as Stream from "effect/Stream";
24import Constants from "expo-constants";
24import * as Network from "expo-network";25import * as Network from "expo-network";
25import { AppState } from "react-native";26import { AppState } from "react-native";
2627
...@@ -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 @@
1import type { AuthClientPresentationMetadata } from "@t3tools/contracts";1import type { AuthClientPresentationMetadata } from "@t3tools/contracts";
2import { Platform } from "react-native";2import { Platform } from "react-native";
33
4export function authClientMetadata(): AuthClientPresentationMetadata {4export 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 });
3139
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";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as TestClock from "effect/testing/TestClock";6import * as TestClock from "effect/testing/TestClock";
7import * as SqlClient from "effect/unstable/sql/SqlClient";
78
8import * as ServerConfig from "../config.ts";9import * as ServerConfig from "../config.ts";
9import { PersistenceSqlError } from "../persistence/Errors.ts";10import { 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});
5153
52const failingSessionLookupCredentialLayer = Layer.effect(54const 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";
10import * as Context from "effect/Context";11import * 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") {}
401409
...@@ -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 );
546554
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});
917948
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+51
...@@ -1379,4 +1379,55 @@ describe("OrchestrationEngine", () => {...@@ -1379,4 +1379,55 @@ describe("OrchestrationEngine", () => {
13791379
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 @@
1import type {1import 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
5455
55interface CommandEnvelope {56interface 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* sql197 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);
331342
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 OrchestrationEngineService11 * @module OrchestrationEngineService
12 */12 */
13import type { OrchestrationCommand, OrchestrationEvent } from "@t3tools/contracts";13import type {
14 OrchestrationClientOrigin,
15 OrchestrationCommand,
16 OrchestrationEvent,
17} from "@t3tools/contracts";
14import * as Context from "effect/Context";18import * as Context from "effect/Context";
15import type * as Effect from "effect/Effect";19import type * as Effect from "effect/Effect";
16import type * as Stream from "effect/Stream";20import 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 via52 * 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>;
5259
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";
1516
...@@ -82,6 +83,13 @@ export const SetAuthSessionLastConnectedAtInput = Schema.Struct({...@@ -82,6 +83,13 @@ export const SetAuthSessionLastConnectedAtInput = Schema.Struct({
82});83});
83export type SetAuthSessionLastConnectedAtInput = typeof SetAuthSessionLastConnectedAtInput.Type;84export type SetAuthSessionLastConnectedAtInput = typeof SetAuthSessionLastConnectedAtInput.Type;
8485
86export const SetAuthSessionClientConnectionInput = Schema.Struct({
87 sessionId: AuthSessionId,
88 surface: Schema.NullOr(ClientSurface),
89 appVersion: Schema.NullOr(Schema.String),
90});
91export type SetAuthSessionClientConnectionInput = typeof SetAuthSessionClientConnectionInput.Type;
92
85export class AuthSessionRepository extends Context.Service<93export 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") {}
108119
...@@ -281,6 +292,20 @@ export const make = Effect.gen(function* () {...@@ -281,6 +292,20 @@ export const make = Effect.gen(function* () {
281 `,292 `,
282 });293 });
283294
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 );
406431
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});
416453
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";
53import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts";53import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts";
54import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts";54import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts";
55import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts";55import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts";
56import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts";
5657
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;
109111
110export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);112export 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 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";
5
6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "../NodeSqliteClient.ts";
8
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
10
11layer("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 @@
1import * as Effect from "effect/Effect";
2import * 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.
7export 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
151import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts";151import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts";
152import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";152import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
153import * as UsageService from "./usage/UsageService.ts";153import * as UsageService from "./usage/UsageService.ts";
154import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
154import * as Data from "effect/Data";155import * as Data from "effect/Data";
155156
156import { makeOrchestrationIntegrationHarness } from "../integration/OrchestrationEngineHarness.integration.ts";157import { 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 );
50375046
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";
106import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";108import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
107import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";109import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
108import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";110import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
111import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
109import * as UsageService from "./usage/UsageService.ts";112import * as UsageService from "./usage/UsageService.ts";
110import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";113import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
111import * as PullRequestService from "./pullRequest/PullRequestService.ts";114import * as PullRequestService from "./pullRequest/PullRequestService.ts";
...@@ -349,8 +352,37 @@ function toAuthAccessStreamEvent(...@@ -349,8 +352,37 @@ function toAuthAccessStreamEvent(
349 }352 }
350}353}
351354
355const isClientSurface = Schema.is(ClientSurface);
356const 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.
361function 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
378const clientOriginAnalyticsProps = (origin: OrchestrationClientOrigin) => ({
379 ...(origin.surface !== undefined ? { surface: origin.surface } : {}),
380 ...(origin.appVersion !== undefined ? { appVersion: origin.appVersion } : {}),
381});
382
352const makeWsRpcLayer = (383const 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 createdThread830 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 = (
896957
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 = (
9551016
956 yield* runSetupProgram();1017 yield* runSetupProgram();
9571018
958 return yield* orchestrationEngine.dispatch(finalTurnStartCommand);1019 return yield* dispatchFromClient(finalTurnStartCommand);
959 });1020 });
9601021
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.bootstrap1057 normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap
997 ? dispatchBootstrapTurnStart(normalizedCommand)1058 ? dispatchBootstrapTurnStart(normalizedCommand)
998 : orchestrationEngine1059 : 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 );
10051064
1006 return startup1065 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";
42import * as Stream from "effect/Stream";42import * as Stream from "effect/Stream";
43import { FetchHttpClient } from "effect/unstable/http";43import { FetchHttpClient } from "effect/unstable/http";
4444
45import { APP_VERSION } from "../branding";
45import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth";46import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth";
46import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer";47import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer";
47import {48import {
...@@ -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}
125128
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});
3434
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.
37export 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
35export const exchangeRemoteDpopAccessToken = Effect.fn(49export 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});
192208
...@@ -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 @@
1import type { AuthClientPresentationMetadata } from "@t3tools/contracts";
1import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay";2import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay";
2import { withRelayClientTracing } from "@t3tools/shared/relayTracing";3import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
3import * as Context from "effect/Context";4import * 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";
6import * as Option from "effect/Option";7import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
89
10import { appendClientConnectionParams } from "../authorization/remote.ts";
9import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";11import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
10import * as ManagedRelay from "../relay/managedRelay.ts";12import * as ManagedRelay from "../relay/managedRelay.ts";
11import * as ClientCapabilities from "../platform/capabilities.ts";13import * as ClientCapabilities from "../platform/capabilities.ts";
...@@ -46,16 +48,21 @@ const isBearerProfile = Schema.is(BearerConnectionProfile);...@@ -46,16 +48,21 @@ const isBearerProfile = Schema.is(BearerConnectionProfile);
46const isSshProfile = Schema.is(SshConnectionProfile);48const isSshProfile = Schema.is(SshConnectionProfile);
47const isBearerCredential = Schema.is(BearerConnectionCredential);49const isBearerCredential = Schema.is(BearerConnectionCredential);
4850
49function primarySocketUrl(target: PrimaryConnectionTarget): string {51function 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}
5662
57const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary")(function* () {63const 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;
6067
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 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema";2import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema";
33
4import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts";4import { AuthSessionId, ClientSurface, TrimmedNonEmptyString } from "./baseSchemas.ts";
55
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});
173export type AuthClientPresentationMetadata = typeof AuthClientPresentationMetadata.Type;175export type AuthClientPresentationMetadata = typeof AuthClientPresentationMetadata.Type;
174176
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;
71export const RpcClientId = NonNegativeInt.pipe(Schema.brand("RpcClientId"));71export const RpcClientId = NonNegativeInt.pipe(Schema.brand("RpcClientId"));
72export type RpcClientId = typeof RpcClientId.Type;72export type RpcClientId = typeof RpcClientId.Type;
7373
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 */
80export const ClientSurface = Schema.Literals(["web", "desktop", "mobile"]);
81export type ClientSurface = typeof ClientSurface.Type;
82
74export const ProviderItemId = makeEntityId("ProviderItemId");83export const ProviderItemId = makeEntityId("ProviderItemId");
75export type ProviderItemId = typeof ProviderItemId.Type;84export type ProviderItemId = typeof ProviderItemId.Type;
76export const RuntimeSessionId = makeEntityId("RuntimeSessionId");85export 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";
8import {8import {
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});
13211322
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 */
1329export const OrchestrationClientOrigin = Schema.Struct({
1330 surface: Schema.optional(ClientSurface),
1331 appVersion: Schema.optional(TrimmedNonEmptyString),
1332});
1333export type OrchestrationClientOrigin = typeof OrchestrationClientOrigin.Type;
1334
1322export const OrchestrationEventMetadata = Schema.Struct({1335export 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});
1329export type OrchestrationEventMetadata = typeof OrchestrationEventMetadata.Type;1343export type OrchestrationEventMetadata = typeof OrchestrationEventMetadata.Type;
13301344