authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-19 21:29:25-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-19 21:29:25-07:00
logccf8331600f323939a796169dfe2598a4c1b59c4
tree2c6d22cad818c99435435ec9f584e2daa700ac77
parentffae12f329c4de9680efd1757b488a3b5bbe3cd2
signature Signed by PGP key B5690EEEBB952194

[codex] Align diagnostics and telemetry Effect services (#3189)

Co-authored-by: codex <codex@users.noreply.github.com>

20 files changed, 718 insertions(+), 556 deletions(-)

apps/server/src/diagnostics/ProcessDiagnostics.ts+103-40
...@@ -12,7 +12,8 @@ import * as Effect from "effect/Effect";...@@ -12,7 +12,8 @@ import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";12import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";13import * as Option from "effect/Option";
14import * as Schema from "effect/Schema";14import * as Schema from "effect/Schema";
15import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";15import * as ChildProcess from "effect/unstable/process/ChildProcess";
16import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1617
17import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";18import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
1819
...@@ -31,35 +32,80 @@ const PROCESS_QUERY_TIMEOUT_MS = 1_000;...@@ -31,35 +32,80 @@ const PROCESS_QUERY_TIMEOUT_MS = 1_000;
31const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=";32const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=";
32const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024;33const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
3334
34export interface ProcessDiagnosticsShape {
35 readonly read: Effect.Effect<ServerProcessDiagnosticsResult>;
36 readonly signal: (input: {
37 readonly pid: number;
38 readonly signal: ServerProcessSignal;
39 }) => Effect.Effect<ServerSignalProcessResult>;
40}
41
42export class ProcessDiagnostics extends Context.Service<35export class ProcessDiagnostics extends Context.Service<
43 ProcessDiagnostics,36 ProcessDiagnostics,
44 ProcessDiagnosticsShape37 {
38 readonly read: Effect.Effect<ServerProcessDiagnosticsResult>;
39 readonly signal: (input: {
40 readonly pid: number;
41 readonly signal: ServerProcessSignal;
42 }) => Effect.Effect<ServerSignalProcessResult>;
43 }
45>()("t3/diagnostics/ProcessDiagnostics") {}44>()("t3/diagnostics/ProcessDiagnostics") {}
4645
47class ProcessDiagnosticsError extends Schema.TaggedErrorClass<ProcessDiagnosticsError>()(46class ProcessDiagnosticsQueryTimeoutError extends Schema.TaggedErrorClass<ProcessDiagnosticsQueryTimeoutError>()(
48 "ProcessDiagnosticsError",47 "ProcessDiagnosticsQueryTimeoutError",
48 { command: Schema.String },
49) {
50 override get message(): string {
51 return `Process diagnostics query '${this.command}' timed out.`;
52 }
53}
54
55class ProcessDiagnosticsQueryFailedError extends Schema.TaggedErrorClass<ProcessDiagnosticsQueryFailedError>()(
56 "ProcessDiagnosticsQueryFailedError",
49 {57 {
50 message: Schema.String,58 command: Schema.String,
59 stderr: Schema.optional(Schema.String),
51 cause: Schema.optional(Schema.Defect()),60 cause: Schema.optional(Schema.Defect()),
52 },61 },
53) {}62) {
54const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError);63 override get message(): string {
64 return this.stderr?.trim() || `Failed to query process diagnostics with '${this.command}'.`;
65 }
66}
5567
56function toProcessDiagnosticsError(message: string, cause?: unknown): ProcessDiagnosticsError {68class ProcessDiagnosticsServerProcessSignalError extends Schema.TaggedErrorClass<ProcessDiagnosticsServerProcessSignalError>()(
57 return new ProcessDiagnosticsError({69 "ProcessDiagnosticsServerProcessSignalError",
58 message,70 { pid: Schema.Number },
59 ...(cause === undefined ? {} : { cause }),71) {
60 });72 override get message(): string {
73 return "Refusing to signal the T3 server process.";
74 }
75}
76
77class ProcessDiagnosticsNotDescendantError extends Schema.TaggedErrorClass<ProcessDiagnosticsNotDescendantError>()(
78 "ProcessDiagnosticsNotDescendantError",
79 { pid: Schema.Number },
80) {
81 override get message(): string {
82 return `Process ${this.pid} is not a live descendant of the T3 server.`;
83 }
61}84}
6285
86class ProcessDiagnosticsSignalFailedError extends Schema.TaggedErrorClass<ProcessDiagnosticsSignalFailedError>()(
87 "ProcessDiagnosticsSignalFailedError",
88 {
89 pid: Schema.Number,
90 signal: Schema.String,
91 cause: Schema.Defect(),
92 },
93) {
94 override get message(): string {
95 return `Failed to signal process ${this.pid} with ${this.signal}.`;
96 }
97}
98
99const ProcessDiagnosticsError = Schema.Union([
100 ProcessDiagnosticsQueryTimeoutError,
101 ProcessDiagnosticsQueryFailedError,
102 ProcessDiagnosticsServerProcessSignalError,
103 ProcessDiagnosticsNotDescendantError,
104 ProcessDiagnosticsSignalFailedError,
105]);
106type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type;
107const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError);
108
63function parsePositiveInt(value: string): number | null {109function parsePositiveInt(value: string): number | null {
64 const parsed = Number.parseInt(value, 10);110 const parsed = Number.parseInt(value, 10);
65 return Number.isInteger(parsed) && parsed > 0 ? parsed : null;111 return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
...@@ -272,11 +318,7 @@ interface ProcessOutput {...@@ -272,11 +318,7 @@ interface ProcessOutput {
272}318}
273319
274const runProcess = Effect.fn("runProcess")(320const runProcess = Effect.fn("runProcess")(
275 function* (input: {321 function* (input: { readonly command: string; readonly args: ReadonlyArray<string> }) {
276 readonly command: string;
277 readonly args: ReadonlyArray<string>;
278 readonly errorMessage: string;
279 }) {
280 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;322 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
281 // `ps` and `powershell.exe` are real executables; spawning through cmd.exe323 // `ps` and `powershell.exe` are real executables; spawning through cmd.exe
282 // shell mode would re-tokenize the PowerShell `-Command` payload (which324 // shell mode would re-tokenize the PowerShell `-Command` payload (which
...@@ -315,14 +357,22 @@ const runProcess = Effect.fn("runProcess")(...@@ -315,14 +357,22 @@ const runProcess = Effect.fn("runProcess")(
315 Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)),357 Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)),
316 Effect.flatMap((result) =>358 Effect.flatMap((result) =>
317 Option.match(result, {359 Option.match(result, {
318 onNone: () => Effect.fail(toProcessDiagnosticsError(`${input.errorMessage} timed out.`)),360 onNone: () =>
361 Effect.fail(
362 new ProcessDiagnosticsQueryTimeoutError({
363 command: input.command,
364 }),
365 ),
319 onSome: Effect.succeed,366 onSome: Effect.succeed,
320 }),367 }),
321 ),368 ),
322 Effect.mapError((cause) =>369 Effect.mapError((cause) =>
323 isProcessDiagnosticsError(cause)370 isProcessDiagnosticsError(cause)
324 ? cause371 ? cause
325 : toProcessDiagnosticsError(input.errorMessage, cause),372 : new ProcessDiagnosticsQueryFailedError({
373 command: input.command,
374 cause,
375 }),
326 ),376 ),
327 ),377 ),
328);378);
...@@ -335,11 +385,15 @@ function readPosixProcessRows(): Effect.Effect<...@@ -335,11 +385,15 @@ function readPosixProcessRows(): Effect.Effect<
335 return runProcess({385 return runProcess({
336 command: "ps",386 command: "ps",
337 args: ["-axo", POSIX_PROCESS_QUERY_COMMAND],387 args: ["-axo", POSIX_PROCESS_QUERY_COMMAND],
338 errorMessage: "Failed to query process diagnostics.",
339 }).pipe(388 }).pipe(
340 Effect.flatMap((result) =>389 Effect.flatMap((result) =>
341 result.exitCode !== 0390 result.exitCode !== 0
342 ? Effect.fail(toProcessDiagnosticsError(result.stderr.trim() || "ps failed."))391 ? Effect.fail(
392 new ProcessDiagnosticsQueryFailedError({
393 command: "ps",
394 stderr: result.stderr.trim() || "ps failed.",
395 }),
396 )
343 : Effect.succeed(parsePosixProcessRows(result.stdout)),397 : Effect.succeed(parsePosixProcessRows(result.stdout)),
344 ),398 ),
345 );399 );
...@@ -361,12 +415,14 @@ function readWindowsProcessRows(): Effect.Effect<...@@ -361,12 +415,14 @@ function readWindowsProcessRows(): Effect.Effect<
361 return runProcess({415 return runProcess({
362 command: "powershell.exe",416 command: "powershell.exe",
363 args: ["-NoProfile", "-NonInteractive", "-Command", command],417 args: ["-NoProfile", "-NonInteractive", "-Command", command],
364 errorMessage: "Failed to query process diagnostics.",
365 }).pipe(418 }).pipe(
366 Effect.flatMap((result) =>419 Effect.flatMap((result) =>
367 result.exitCode !== 0420 result.exitCode !== 0
368 ? Effect.fail(421 ? Effect.fail(
369 toProcessDiagnosticsError(result.stderr.trim() || "PowerShell process query failed."),422 new ProcessDiagnosticsQueryFailedError({
423 command: "powershell.exe",
424 stderr: result.stderr.trim() || "PowerShell process query failed.",
425 }),
370 )426 )
371 : Effect.succeed(parseWindowsProcessRows(result.stdout)),427 : Effect.succeed(parseWindowsProcessRows(result.stdout)),
372 ),428 ),
...@@ -390,7 +446,11 @@ function assertDescendantPid(...@@ -390,7 +446,11 @@ function assertDescendantPid(
390 pid: number,446 pid: number,
391): Effect.Effect<void, ProcessDiagnosticsError, ChildProcessSpawner.ChildProcessSpawner> {447): Effect.Effect<void, ProcessDiagnosticsError, ChildProcessSpawner.ChildProcessSpawner> {
392 if (pid === process.pid) {448 if (pid === process.pid) {
393 return Effect.fail(toProcessDiagnosticsError("Refusing to signal the T3 server process."));449 return Effect.fail(
450 new ProcessDiagnosticsServerProcessSignalError({
451 pid,
452 }),
453 );
394 }454 }
395455
396 return readProcessRows.pipe(456 return readProcessRows.pipe(
...@@ -402,16 +462,18 @@ function assertDescendantPid(...@@ -402,16 +462,18 @@ function assertDescendantPid(
402 return descendant462 return descendant
403 ? Effect.void463 ? Effect.void
404 : Effect.fail(464 : Effect.fail(
405 toProcessDiagnosticsError(`Process ${pid} is not a live descendant of the T3 server.`),465 new ProcessDiagnosticsNotDescendantError({
466 pid,
467 }),
406 );468 );
407 }),469 }),
408 );470 );
409}471}
410472
411export const make = Effect.fn("makeProcessDiagnostics")(function* () {473export const make = Effect.gen(function* () {
412 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;474 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
413475
414 const read: ProcessDiagnosticsShape["read"] = Effect.gen(function* () {476 const read: ProcessDiagnostics["Service"]["read"] = Effect.gen(function* () {
415 const readAt = yield* DateTime.now;477 const readAt = yield* DateTime.now;
416 const rows = yield* readProcessRows.pipe(478 const rows = yield* readProcessRows.pipe(
417 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),479 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
...@@ -427,7 +489,7 @@ export const make = Effect.fn("makeProcessDiagnostics")(function* () {...@@ -427,7 +489,7 @@ export const make = Effect.fn("makeProcessDiagnostics")(function* () {
427 ),489 ),
428 );490 );
429491
430 const signal: ProcessDiagnosticsShape["signal"] = Effect.fn("ProcessDiagnostics.signal")(492 const signal: ProcessDiagnostics["Service"]["signal"] = Effect.fn("ProcessDiagnostics.signal")(
431 function* (input) {493 function* (input) {
432 return yield* assertDescendantPid(input.pid).pipe(494 return yield* assertDescendantPid(input.pid).pipe(
433 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),495 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
...@@ -443,10 +505,11 @@ export const make = Effect.fn("makeProcessDiagnostics")(function* () {...@@ -443,10 +505,11 @@ export const make = Effect.fn("makeProcessDiagnostics")(function* () {
443 };505 };
444 },506 },
445 catch: (cause) =>507 catch: (cause) =>
446 toProcessDiagnosticsError(508 new ProcessDiagnosticsSignalFailedError({
447 `Failed to signal process ${input.pid} with ${input.signal}.`,509 pid: input.pid,
510 signal: input.signal,
448 cause,511 cause,
449 ),512 }),
450 }),513 }),
451 ),514 ),
452 Effect.catch((error: ProcessDiagnosticsError) =>515 Effect.catch((error: ProcessDiagnosticsError) =>
...@@ -464,4 +527,4 @@ export const make = Effect.fn("makeProcessDiagnostics")(function* () {...@@ -464,4 +527,4 @@ export const make = Effect.fn("makeProcessDiagnostics")(function* () {
464 return ProcessDiagnostics.of({ read, signal });527 return ProcessDiagnostics.of({ read, signal });
465});528});
466529
467export const layer = Layer.effect(ProcessDiagnostics, make());530export const layer = Layer.effect(ProcessDiagnostics, make);
apps/server/src/diagnostics/ProcessResourceMonitor.test.ts+10-13
...@@ -3,16 +3,13 @@ import * as DateTime from "effect/DateTime";...@@ -3,16 +3,13 @@ import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
55
6import {6import * as ProcessResourceMonitor from "./ProcessResourceMonitor.ts";
7 aggregateProcessResourceHistory,
8 collectMonitoredSamples,
9} from "./ProcessResourceMonitor.ts";
107
11describe("ProcessResourceMonitor", () => {8describe("ProcessResourceMonitor", () => {
12 it.effect("samples the server root process and descendants", () =>9 it.effect("samples the server root process and descendants", () =>
13 Effect.sync(() => {10 Effect.sync(() => {
14 const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");11 const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
15 const samples = collectMonitoredSamples({12 const samples = ProcessResourceMonitor.collectMonitoredSamples({
16 serverPid: 100,13 serverPid: 100,
17 sampledAt,14 sampledAt,
18 sampledAtMs: DateTime.toEpochMillis(sampledAt),15 sampledAtMs: DateTime.toEpochMillis(sampledAt),
...@@ -72,7 +69,7 @@ describe("ProcessResourceMonitor", () => {...@@ -72,7 +69,7 @@ describe("ProcessResourceMonitor", () => {
72 const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");69 const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
73 const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.000Z");70 const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.000Z");
74 const samples = [71 const samples = [
75 ...collectMonitoredSamples({72 ...ProcessResourceMonitor.collectMonitoredSamples({
76 serverPid: 100,73 serverPid: 100,
77 sampledAt: firstAt,74 sampledAt: firstAt,
78 sampledAtMs: DateTime.toEpochMillis(firstAt),75 sampledAtMs: DateTime.toEpochMillis(firstAt),
...@@ -89,7 +86,7 @@ describe("ProcessResourceMonitor", () => {...@@ -89,7 +86,7 @@ describe("ProcessResourceMonitor", () => {
89 },86 },
90 ],87 ],
91 }),88 }),
92 ...collectMonitoredSamples({89 ...ProcessResourceMonitor.collectMonitoredSamples({
93 serverPid: 100,90 serverPid: 100,
94 sampledAt: secondAt,91 sampledAt: secondAt,
95 sampledAtMs: DateTime.toEpochMillis(secondAt),92 sampledAtMs: DateTime.toEpochMillis(secondAt),
...@@ -108,7 +105,7 @@ describe("ProcessResourceMonitor", () => {...@@ -108,7 +105,7 @@ describe("ProcessResourceMonitor", () => {
108 }),105 }),
109 ];106 ];
110107
111 const result = aggregateProcessResourceHistory({108 const result = ProcessResourceMonitor.aggregateProcessResourceHistory({
112 samples,109 samples,
113 readAt: secondAt,110 readAt: secondAt,
114 readAtMs: DateTime.toEpochMillis(secondAt),111 readAtMs: DateTime.toEpochMillis(secondAt),
...@@ -132,7 +129,7 @@ describe("ProcessResourceMonitor", () => {...@@ -132,7 +129,7 @@ describe("ProcessResourceMonitor", () => {
132 const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.400Z");129 const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.400Z");
133 const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.900Z");130 const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.900Z");
134 const samples = [131 const samples = [
135 ...collectMonitoredSamples({132 ...ProcessResourceMonitor.collectMonitoredSamples({
136 serverPid: 100,133 serverPid: 100,
137 sampledAt: firstAt,134 sampledAt: firstAt,
138 sampledAtMs: DateTime.toEpochMillis(firstAt),135 sampledAtMs: DateTime.toEpochMillis(firstAt),
...@@ -149,7 +146,7 @@ describe("ProcessResourceMonitor", () => {...@@ -149,7 +146,7 @@ describe("ProcessResourceMonitor", () => {
149 },146 },
150 ],147 ],
151 }),148 }),
152 ...collectMonitoredSamples({149 ...ProcessResourceMonitor.collectMonitoredSamples({
153 serverPid: 100,150 serverPid: 100,
154 sampledAt: secondAt,151 sampledAt: secondAt,
155 sampledAtMs: DateTime.toEpochMillis(secondAt),152 sampledAtMs: DateTime.toEpochMillis(secondAt),
...@@ -168,7 +165,7 @@ describe("ProcessResourceMonitor", () => {...@@ -168,7 +165,7 @@ describe("ProcessResourceMonitor", () => {
168 }),165 }),
169 ];166 ];
170167
171 const result = aggregateProcessResourceHistory({168 const result = ProcessResourceMonitor.aggregateProcessResourceHistory({
172 samples,169 samples,
173 readAt: secondAt,170 readAt: secondAt,
174 readAtMs: DateTime.toEpochMillis(secondAt),171 readAtMs: DateTime.toEpochMillis(secondAt),
...@@ -187,7 +184,7 @@ describe("ProcessResourceMonitor", () => {...@@ -187,7 +184,7 @@ describe("ProcessResourceMonitor", () => {
187 it.effect("returns all process summaries in the selected window", () =>184 it.effect("returns all process summaries in the selected window", () =>
188 Effect.sync(() => {185 Effect.sync(() => {
189 const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");186 const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
190 const samples = collectMonitoredSamples({187 const samples = ProcessResourceMonitor.collectMonitoredSamples({
191 serverPid: 100,188 serverPid: 100,
192 sampledAt,189 sampledAt,
193 sampledAtMs: DateTime.toEpochMillis(sampledAt),190 sampledAtMs: DateTime.toEpochMillis(sampledAt),
...@@ -215,7 +212,7 @@ describe("ProcessResourceMonitor", () => {...@@ -215,7 +212,7 @@ describe("ProcessResourceMonitor", () => {
215 ],212 ],
216 });213 });
217214
218 const result = aggregateProcessResourceHistory({215 const result = ProcessResourceMonitor.aggregateProcessResourceHistory({
219 samples,216 samples,
220 readAt: sampledAt,217 readAt: sampledAt,
221 readAtMs: DateTime.toEpochMillis(sampledAt),218 readAtMs: DateTime.toEpochMillis(sampledAt),
apps/server/src/diagnostics/ProcessResourceMonitor.ts+21-23
...@@ -10,14 +10,9 @@ import * as Effect from "effect/Effect";...@@ -10,14 +10,9 @@ import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";11import * as Option from "effect/Option";
12import * as Ref from "effect/Ref";12import * as Ref from "effect/Ref";
13import { ChildProcessSpawner } from "effect/unstable/process";13import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1414
15import {15import * as ProcessDiagnostics from "./ProcessDiagnostics.ts";
16 buildDescendantEntries,
17 isDiagnosticsQueryProcess,
18 type ProcessRow,
19 readProcessRows,
20} from "./ProcessDiagnostics.ts";
2116
22const SAMPLE_INTERVAL_MS = 5_000;17const SAMPLE_INTERVAL_MS = 5_000;
23const RETENTION_MS = 60 * 60_000;18const RETENTION_MS = 60 * 60_000;
...@@ -41,38 +36,41 @@ interface MonitorState {...@@ -41,38 +36,41 @@ interface MonitorState {
41 readonly lastError: string | null;36 readonly lastError: string | null;
42}37}
4338
44export interface ProcessResourceMonitorShape {
45 readonly readHistory: (
46 input: ServerProcessResourceHistoryInput,
47 ) => Effect.Effect<ServerProcessResourceHistoryResult>;
48}
49
50export class ProcessResourceMonitor extends Context.Service<39export class ProcessResourceMonitor extends Context.Service<
51 ProcessResourceMonitor,40 ProcessResourceMonitor,
52 ProcessResourceMonitorShape41 {
42 readonly readHistory: (
43 input: ServerProcessResourceHistoryInput,
44 ) => Effect.Effect<ServerProcessResourceHistoryResult>;
45 }
53>()("t3/diagnostics/ProcessResourceMonitor") {}46>()("t3/diagnostics/ProcessResourceMonitor") {}
5447
55function dateTimeFromMillis(ms: number): DateTime.Utc {48function dateTimeFromMillis(ms: number): DateTime.Utc {
56 return DateTime.makeUnsafe(ms);49 return DateTime.makeUnsafe(ms);
57}50}
5851
59function sampleKey(row: Pick<ProcessRow, "pid" | "command">): string {52function sampleKey(row: Pick<ProcessDiagnostics.ProcessRow, "pid" | "command">): string {
60 return `${row.pid}:${row.command}`;53 return `${row.pid}:${row.command}`;
61}54}
6255
63function findServerRootRow(rows: ReadonlyArray<ProcessRow>, serverPid: number): ProcessRow | null {56function findServerRootRow(
57 rows: ReadonlyArray<ProcessDiagnostics.ProcessRow>,
58 serverPid: number,
59): ProcessDiagnostics.ProcessRow | null {
64 return rows.find((row) => row.pid === serverPid) ?? null;60 return rows.find((row) => row.pid === serverPid) ?? null;
65}61}
6662
67export function collectMonitoredSamples(input: {63export function collectMonitoredSamples(input: {
68 readonly rows: ReadonlyArray<ProcessRow>;64 readonly rows: ReadonlyArray<ProcessDiagnostics.ProcessRow>;
69 readonly serverPid: number;65 readonly serverPid: number;
70 readonly sampledAt: DateTime.Utc;66 readonly sampledAt: DateTime.Utc;
71 readonly sampledAtMs: number;67 readonly sampledAtMs: number;
72}): ReadonlyArray<ProcessResourceSample> {68}): ReadonlyArray<ProcessResourceSample> {
73 const rows = input.rows.filter((row) => !isDiagnosticsQueryProcess(row, input.serverPid));69 const rows = input.rows.filter(
70 (row) => !ProcessDiagnostics.isDiagnosticsQueryProcess(row, input.serverPid),
71 );
74 const root = findServerRootRow(rows, input.serverPid);72 const root = findServerRootRow(rows, input.serverPid);
75 const descendants = buildDescendantEntries(rows, input.serverPid);73 const descendants = ProcessDiagnostics.buildDescendantEntries(rows, input.serverPid);
76 const samples: ProcessResourceSample[] = [];74 const samples: ProcessResourceSample[] = [];
7775
78 if (root) {76 if (root) {
...@@ -245,14 +243,14 @@ export function aggregateProcessResourceHistory(input: {...@@ -245,14 +243,14 @@ export function aggregateProcessResourceHistory(input: {
245 };243 };
246}244}
247245
248export const make = Effect.fn("makeProcessResourceMonitor")(function* () {246export const make = Effect.gen(function* () {
249 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;247 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
250 const state = yield* Ref.make<MonitorState>({ samples: [], lastError: null });248 const state = yield* Ref.make<MonitorState>({ samples: [], lastError: null });
251249
252 const sampleOnce = Effect.gen(function* () {250 const sampleOnce = Effect.gen(function* () {
253 const sampledAt = yield* DateTime.now;251 const sampledAt = yield* DateTime.now;
254 const sampledAtMs = DateTime.toEpochMillis(sampledAt);252 const sampledAtMs = DateTime.toEpochMillis(sampledAt);
255 const rows = yield* readProcessRows.pipe(253 const rows = yield* ProcessDiagnostics.readProcessRows.pipe(
256 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),254 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
257 );255 );
258 const samples = collectMonitoredSamples({256 const samples = collectMonitoredSamples({
...@@ -278,7 +276,7 @@ export const make = Effect.fn("makeProcessResourceMonitor")(function* () {...@@ -278,7 +276,7 @@ export const make = Effect.fn("makeProcessResourceMonitor")(function* () {
278 Effect.forkScoped,276 Effect.forkScoped,
279 );277 );
280278
281 const readHistory: ProcessResourceMonitorShape["readHistory"] = (input) =>279 const readHistory: ProcessResourceMonitor["Service"]["readHistory"] = (input) =>
282 Effect.gen(function* () {280 Effect.gen(function* () {
283 const readAt = yield* DateTime.now;281 const readAt = yield* DateTime.now;
284 const readAtMs = DateTime.toEpochMillis(readAt);282 const readAtMs = DateTime.toEpochMillis(readAt);
...@@ -296,4 +294,4 @@ export const make = Effect.fn("makeProcessResourceMonitor")(function* () {...@@ -296,4 +294,4 @@ export const make = Effect.fn("makeProcessResourceMonitor")(function* () {
296 return ProcessResourceMonitor.of({ readHistory });294 return ProcessResourceMonitor.of({ readHistory });
297});295});
298296
299export const layer = Layer.effect(ProcessResourceMonitor, make());297export const layer = Layer.effect(ProcessResourceMonitor, make);
apps/server/src/diagnostics/TraceDiagnostics.ts+11-10
...@@ -39,13 +39,14 @@ export interface TraceDiagnosticsOptions {...@@ -39,13 +39,14 @@ export interface TraceDiagnosticsOptions {
39 readonly readAt?: DateTime.Utc;39 readonly readAt?: DateTime.Utc;
40}40}
4141
42export interface TraceDiagnosticsShape {42export class TraceDiagnostics extends Context.Service<
43 readonly read: (options: TraceDiagnosticsOptions) => Effect.Effect<ServerTraceDiagnosticsResult>;43 TraceDiagnostics,
44}44 {
4545 readonly read: (
46export class TraceDiagnostics extends Context.Service<TraceDiagnostics, TraceDiagnosticsShape>()(46 options: TraceDiagnosticsOptions,
47 "t3/diagnostics/TraceDiagnostics",47 ) => Effect.Effect<ServerTraceDiagnosticsResult>;
48) {}48 }
49>()("t3/diagnostics/TraceDiagnostics") {}
4950
50interface TraceDiagnosticsInput {51interface TraceDiagnosticsInput {
51 readonly traceFilePath: string;52 readonly traceFilePath: string;
...@@ -395,10 +396,10 @@ function readTraceFile(...@@ -395,10 +396,10 @@ function readTraceFile(
395 );396 );
396}397}
397398
398export const make = Effect.fn("makeTraceDiagnostics")(function* () {399export const make = Effect.gen(function* () {
399 const fileSystem = yield* FileSystem.FileSystem;400 const fileSystem = yield* FileSystem.FileSystem;
400401
401 const read: TraceDiagnosticsShape["read"] = Effect.fn("TraceDiagnostics.read")(402 const read: TraceDiagnostics["Service"]["read"] = Effect.fn("TraceDiagnostics.read")(
402 function* (options) {403 function* (options) {
403 const readAt = options.readAt ?? (yield* DateTime.now);404 const readAt = options.readAt ?? (yield* DateTime.now);
404 const slowSpanThresholdMs = options.slowSpanThresholdMs ?? DEFAULT_SLOW_SPAN_THRESHOLD_MS;405 const slowSpanThresholdMs = options.slowSpanThresholdMs ?? DEFAULT_SLOW_SPAN_THRESHOLD_MS;
...@@ -449,7 +450,7 @@ export const make = Effect.fn("makeTraceDiagnostics")(function* () {...@@ -449,7 +450,7 @@ export const make = Effect.fn("makeTraceDiagnostics")(function* () {
449 return TraceDiagnostics.of({ read });450 return TraceDiagnostics.of({ read });
450});451});
451452
452export const layer = Layer.effect(TraceDiagnostics, make());453export const layer = Layer.effect(TraceDiagnostics, make);
453454
454export function readTraceDiagnostics(455export function readTraceDiagnostics(
455 options: TraceDiagnosticsOptions,456 options: TraceDiagnosticsOptions,
apps/server/src/http.ts+10-9
...@@ -24,13 +24,13 @@ import {...@@ -24,13 +24,13 @@ import {
24import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";24import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
25import { OtlpTracer } from "effect/unstable/observability";25import { OtlpTracer } from "effect/unstable/observability";
2626
27import { resolveStaticDir, ServerConfig } from "./config.ts";27import * as ServerConfig from "./config.ts";
28import {28import {
29 ASSET_ROUTE_PREFIX,29 ASSET_ROUTE_PREFIX,
30 FALLBACK_PROJECT_FAVICON_SVG,30 FALLBACK_PROJECT_FAVICON_SVG,
31 resolveAsset,31 resolveAsset,
32} from "./assets/AssetAccess.ts";32} from "./assets/AssetAccess.ts";
33import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts";33import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
34import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";34import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
35import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";35import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";
36import {36import {
...@@ -39,7 +39,7 @@ import {...@@ -39,7 +39,7 @@ import {
39 failEnvironmentAuthInvalid,39 failEnvironmentAuthInvalid,
40 failEnvironmentInternal,40 failEnvironmentInternal,
41} from "./auth/http.ts";41} from "./auth/http.ts";
42import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";42import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts";
43import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts";43import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts";
4444
45const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";45const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
...@@ -47,7 +47,7 @@ const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);...@@ -47,7 +47,7 @@ const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
4747
48export const browserApiCorsLayer = Layer.unwrap(48export const browserApiCorsLayer = Layer.unwrap(
49 Effect.gen(function* () {49 Effect.gen(function* () {
50 const config = yield* ServerConfig;50 const config = yield* ServerConfig.ServerConfig;
51 const devOrigin = config.devUrl?.origin;51 const devOrigin = config.devUrl?.origin;
52 return HttpRouter.cors({52 return HttpRouter.cors({
53 ...(devOrigin ? { allowedOrigins: [devOrigin], credentials: true } : {}),53 ...(devOrigin ? { allowedOrigins: [devOrigin], credentials: true } : {}),
...@@ -95,7 +95,7 @@ export const serverEnvironmentHttpApiLayer = HttpApiBuilder.group(...@@ -95,7 +95,7 @@ export const serverEnvironmentHttpApiLayer = HttpApiBuilder.group(
95 EnvironmentHttpApi,95 EnvironmentHttpApi,
96 "metadata",96 "metadata",
97 Effect.fnUntraced(function* (handlers) {97 Effect.fnUntraced(function* (handlers) {
98 const serverEnvironment = yield* ServerEnvironment;98 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
99 return handlers.handle(99 return handlers.handle(
100 "descriptor",100 "descriptor",
101 Effect.fn("environment.metadata.descriptor")(function* (args) {101 Effect.fn("environment.metadata.descriptor")(function* (args) {
...@@ -117,9 +117,9 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(...@@ -117,9 +117,9 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(
117 Effect.gen(function* () {117 Effect.gen(function* () {
118 yield* authenticateRawRouteWithScope(AuthOrchestrationOperateScope);118 yield* authenticateRawRouteWithScope(AuthOrchestrationOperateScope);
119 const request = yield* HttpServerRequest.HttpServerRequest;119 const request = yield* HttpServerRequest.HttpServerRequest;
120 const config = yield* ServerConfig;120 const config = yield* ServerConfig.ServerConfig;
121 const otlpTracesUrl = config.otlpTracesUrl;121 const otlpTracesUrl = config.otlpTracesUrl;
122 const browserTraceCollector = yield* BrowserTraceCollector;122 const browserTraceCollector = yield* BrowserTraceCollector.BrowserTraceCollector;
123 const httpClient = yield* HttpClient.HttpClient;123 const httpClient = yield* HttpClient.HttpClient;
124 const bodyJson = cast<unknown, OtlpTracer.TraceData>(yield* request.json);124 const bodyJson = cast<unknown, OtlpTracer.TraceData>(yield* request.json);
125125
...@@ -223,14 +223,15 @@ export const staticAndDevRouteLayer = HttpRouter.add(...@@ -223,14 +223,15 @@ export const staticAndDevRouteLayer = HttpRouter.add(
223 return HttpServerResponse.text("Bad Request", { status: 400 });223 return HttpServerResponse.text("Bad Request", { status: 400 });
224 }224 }
225225
226 const config = yield* ServerConfig;226 const config = yield* ServerConfig.ServerConfig;
227 if (config.devUrl && isLoopbackHostname(url.value.hostname)) {227 if (config.devUrl && isLoopbackHostname(url.value.hostname)) {
228 return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), {228 return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), {
229 status: 302,229 status: 302,
230 });230 });
231 }231 }
232232
233 const staticDir = config.staticDir ?? (config.devUrl ? yield* resolveStaticDir() : undefined);233 const staticDir =
234 config.staticDir ?? (config.devUrl ? yield* ServerConfig.resolveStaticDir() : undefined);
234 if (!staticDir) {235 if (!staticDir) {
235 return HttpServerResponse.text("No static directory configured and no dev URL set.", {236 return HttpServerResponse.text("No static directory configured and no dev URL set.", {
236 status: 503,237 status: 503,
apps/server/src/observability/BrowserTraceCollector.ts created+23
...@@ -0,0 +1,23 @@
1import type { TraceRecord, TraceSink } from "@t3tools/shared/observability";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6export class BrowserTraceCollector extends Context.Service<
7 BrowserTraceCollector,
8 {
9 readonly record: (records: ReadonlyArray<TraceRecord>) => Effect.Effect<void>;
10 }
11>()("t3/observability/BrowserTraceCollector") {}
12
13export const make = (sink: TraceSink): BrowserTraceCollector["Service"] =>
14 BrowserTraceCollector.of({
15 record: (records) =>
16 Effect.sync(() => {
17 for (const record of records) {
18 sink.push(record);
19 }
20 }),
21 });
22
23export const layer = (sink: TraceSink) => Layer.succeed(BrowserTraceCollector, make(sink));
apps/server/src/observability/Layers/Observability.ts+7-12
...@@ -4,17 +4,19 @@ import * as Effect from "effect/Effect";...@@ -4,17 +4,19 @@ import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import * as References from "effect/References";5import * as References from "effect/References";
6import * as Tracer from "effect/Tracer";6import * as Tracer from "effect/Tracer";
7import { OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/unstable/observability";7import * as OtlpMetrics from "effect/unstable/observability/OtlpMetrics";
8import * as OtlpSerialization from "effect/unstable/observability/OtlpSerialization";
9import * as OtlpTracer from "effect/unstable/observability/OtlpTracer";
810
9import { ServerConfig } from "../../config.ts";11import * as ServerConfig from "../../config.ts";
10import { ServerLoggerLive } from "../../serverLogger.ts";12import { ServerLoggerLive } from "../../serverLogger.ts";
11import { BrowserTraceCollector } from "../Services/BrowserTraceCollector.ts";13import * as BrowserTraceCollector from "../BrowserTraceCollector.ts";
1214
13const otlpSerializationLayer = OtlpSerialization.layerJson;15const otlpSerializationLayer = OtlpSerialization.layerJson;
1416
15export const ObservabilityLive = Layer.unwrap(17export const ObservabilityLive = Layer.unwrap(
16 Effect.gen(function* () {18 Effect.gen(function* () {
17 const config = yield* ServerConfig;19 const config = yield* ServerConfig.ServerConfig;
1820
19 const traceReferencesLayer = Layer.mergeAll(21 const traceReferencesLayer = Layer.mergeAll(
20 Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel),22 Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel),
...@@ -56,14 +58,7 @@ export const ObservabilityLive = Layer.unwrap(...@@ -56,14 +58,7 @@ export const ObservabilityLive = Layer.unwrap(
5658
57 return Layer.mergeAll(59 return Layer.mergeAll(
58 Layer.succeed(Tracer.Tracer, tracer),60 Layer.succeed(Tracer.Tracer, tracer),
59 Layer.succeed(BrowserTraceCollector, {61 BrowserTraceCollector.layer(sink),
60 record: (records) =>
61 Effect.sync(() => {
62 for (const record of records) {
63 sink.push(record);
64 }
65 }),
66 }),
67 );62 );
68 }),63 }),
69 ).pipe(Layer.provideMerge(otlpSerializationLayer));64 ).pipe(Layer.provideMerge(otlpSerializationLayer));
apps/server/src/observability/Services/BrowserTraceCollector.ts deleted-12
...@@ -1,12 +0,0 @@
1import type { TraceRecord } from "@t3tools/shared/observability";
2import * as Context from "effect/Context";
3import type * as Effect from "effect/Effect";
4
5export interface BrowserTraceCollectorShape {
6 readonly record: (records: ReadonlyArray<TraceRecord>) => Effect.Effect<void>;
7}
8
9export class BrowserTraceCollector extends Context.Service<
10 BrowserTraceCollector,
11 BrowserTraceCollectorShape
12>()("t3/observability/Services/BrowserTraceCollector") {}
apps/server/src/provider/Layers/ProviderService.test.ts+164-75
...@@ -43,14 +43,11 @@ import {...@@ -43,14 +43,11 @@ import {
43 type ProviderAdapterError,43 type ProviderAdapterError,
44} from "../Errors.ts";44} from "../Errors.ts";
45import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";45import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
46import {46import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts";
47 ProviderAdapterRegistry,47import * as ProviderService from "../Services/ProviderService.ts";
48 type ProviderAdapterRegistryShape,48import * as ProviderSessionDirectory from "../Services/ProviderSessionDirectory.ts";
49} from "../Services/ProviderAdapterRegistry.ts";
50import { ProviderService } from "../Services/ProviderService.ts";
51import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
52import { makeProviderServiceLive } from "./ProviderService.ts";49import { makeProviderServiceLive } from "./ProviderService.ts";
53import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts";50import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";
54import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";51import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";
55import * as NodeServices from "@effect/platform-node/NodeServices";52import * as NodeServices from "@effect/platform-node/NodeServices";
56import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts";53import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts";
...@@ -58,11 +55,11 @@ import {...@@ -58,11 +55,11 @@ import {
58 makeSqlitePersistenceLive,55 makeSqlitePersistenceLive,
59 SqlitePersistenceMemory,56 SqlitePersistenceMemory,
60} from "../../persistence/Layers/Sqlite.ts";57} from "../../persistence/Layers/Sqlite.ts";
61import { ServerSettingsService } from "../../serverSettings.ts";58import * as ServerSettings from "../../serverSettings.ts";
62import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts";59import * as AnalyticsService from "../../telemetry/AnalyticsService.ts";
63import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts";60import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts";
6461
65const defaultServerSettingsLayer = ServerSettingsService.layerTest();62const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest();
6663
67const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value);64const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value);
68const asEventId = (value: string): EventId => EventId.make(value);65const asEventId = (value: string): EventId => EventId.make(value);
...@@ -280,7 +277,10 @@ function makeProviderServiceLayer() {...@@ -280,7 +277,10 @@ function makeProviderServiceLayer() {
280 [ProviderDriverKind.make("cursor")]: cursor.adapter,277 [ProviderDriverKind.make("cursor")]: cursor.adapter,
281 });278 });
282279
283 const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry);280 const providerAdapterLayer = Layer.succeed(
281 ProviderAdapterRegistry.ProviderAdapterRegistry,
282 registry,
283 );
284 const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(284 const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(
285 Layer.provide(SqlitePersistenceMemory),285 Layer.provide(SqlitePersistenceMemory),
286 );286 );
...@@ -293,7 +293,12 @@ function makeProviderServiceLayer() {...@@ -293,7 +293,12 @@ function makeProviderServiceLayer() {
293 Layer.provide(directoryLayer),293 Layer.provide(directoryLayer),
294 Layer.provide(defaultServerSettingsLayer),294 Layer.provide(defaultServerSettingsLayer),
295 Layer.provideMerge(AnalyticsService.layerTest),295 Layer.provideMerge(AnalyticsService.layerTest),
296 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),296 Layer.provide(
297 Layer.succeed(
298 ProviderEventLoggers.ProviderEventLoggers,
299 ProviderEventLoggers.NoOpProviderEventLoggers,
300 ),
301 ),
297 ),302 ),
298 directoryLayer,303 directoryLayer,
299304
...@@ -325,7 +330,10 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () =>...@@ -325,7 +330,10 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () =>
325 const registry = makeAdapterRegistryMock({330 const registry = makeAdapterRegistryMock({
326 [CODEX_DRIVER]: codex.adapter,331 [CODEX_DRIVER]: codex.adapter,
327 });332 });
328 const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry);333 const providerAdapterLayer = Layer.succeed(
334 ProviderAdapterRegistry.ProviderAdapterRegistry,
335 registry,
336 );
329 const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(337 const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(
330 Layer.provide(SqlitePersistenceMemory),338 Layer.provide(SqlitePersistenceMemory),
331 );339 );
...@@ -336,7 +344,12 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () =>...@@ -336,7 +344,12 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () =>
336 Layer.provide(directoryLayer),344 Layer.provide(directoryLayer),
337 Layer.provide(defaultServerSettingsLayer),345 Layer.provide(defaultServerSettingsLayer),
338 Layer.provideMerge(AnalyticsService.layerTest),346 Layer.provideMerge(AnalyticsService.layerTest),
339 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),347 Layer.provide(
348 Layer.succeed(
349 ProviderEventLoggers.ProviderEventLoggers,
350 ProviderEventLoggers.NoOpProviderEventLoggers,
351 ),
352 ),
340 ),353 ),
341 directoryLayer,354 directoryLayer,
342 runtimeRepositoryLayer,355 runtimeRepositoryLayer,
...@@ -345,7 +358,7 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () =>...@@ -345,7 +358,7 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () =>
345 const scope = yield* Scope.make();358 const scope = yield* Scope.make();
346 const runtimeServices = yield* Layer.build(providerLayer).pipe(Scope.provide(scope));359 const runtimeServices = yield* Layer.build(providerLayer).pipe(Scope.provide(scope));
347360
348 yield* ProviderService.pipe(Effect.provide(runtimeServices));361 yield* ProviderService.ProviderService.pipe(Effect.provide(runtimeServices));
349 const closeExit = yield* Scope.close(scope, Exit.void).pipe(Effect.exit);362 const closeExit = yield* Scope.close(scope, Exit.void).pipe(Effect.exit);
350363
351 assert.equal(Exit.isSuccess(closeExit), true);364 assert.equal(Exit.isSuccess(closeExit), true);
...@@ -361,7 +374,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", ()...@@ -361,7 +374,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", ()
361 [CODEX_DRIVER]: codex.adapter,374 [CODEX_DRIVER]: codex.adapter,
362 [CLAUDE_AGENT_DRIVER]: claude.adapter,375 [CLAUDE_AGENT_DRIVER]: claude.adapter,
363 });376 });
364 const registry: ProviderAdapterRegistryShape = {377 const registry: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"] = {
365 ...registryBase,378 ...registryBase,
366 getInstanceInfo: (instanceId) =>379 getInstanceInfo: (instanceId) =>
367 instanceId === claudeAgentInstanceId380 instanceId === claudeAgentInstanceId
...@@ -377,7 +390,10 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", ()...@@ -377,7 +390,10 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", ()
377 })390 })
378 : registryBase.getInstanceInfo(instanceId),391 : registryBase.getInstanceInfo(instanceId),
379 };392 };
380 const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry);393 const providerAdapterLayer = Layer.succeed(
394 ProviderAdapterRegistry.ProviderAdapterRegistry,
395 registry,
396 );
381 const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(397 const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(
382 Layer.provide(SqlitePersistenceMemory),398 Layer.provide(SqlitePersistenceMemory),
383 );399 );
...@@ -387,12 +403,17 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", ()...@@ -387,12 +403,17 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", ()
387 Layer.provide(directoryLayer),403 Layer.provide(directoryLayer),
388 Layer.provide(defaultServerSettingsLayer),404 Layer.provide(defaultServerSettingsLayer),
389 Layer.provide(AnalyticsService.layerTest),405 Layer.provide(AnalyticsService.layerTest),
390 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),406 Layer.provide(
407 Layer.succeed(
408 ProviderEventLoggers.ProviderEventLoggers,
409 ProviderEventLoggers.NoOpProviderEventLoggers,
410 ),
411 ),
391 );412 );
392413
393 const failure = yield* Effect.flip(414 const failure = yield* Effect.flip(
394 Effect.gen(function* () {415 Effect.gen(function* () {
395 const provider = yield* ProviderService;416 const provider = yield* ProviderService.ProviderService;
396 return yield* provider.startSession(asThreadId("thread-disabled"), {417 return yield* provider.startSession(asThreadId("thread-disabled"), {
397 provider: ProviderDriverKind.make("claudeAgent"),418 provider: ProviderDriverKind.make("claudeAgent"),
398 providerInstanceId: claudeAgentInstanceId,419 providerInstanceId: claudeAgentInstanceId,
...@@ -419,7 +440,7 @@ it.effect(...@@ -419,7 +440,7 @@ it.effect(
419 new ProviderUnsupportedError({440 new ProviderUnsupportedError({
420 provider: driverKind,441 provider: driverKind,
421 });442 });
422 const registry: ProviderAdapterRegistryShape = {443 const registry: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"] = {
423 getByInstance: (requestedInstanceId) =>444 getByInstance: (requestedInstanceId) =>
424 requestedInstanceId === instanceId445 requestedInstanceId === instanceId
425 ? Effect.succeed(codex.adapter)446 ? Effect.succeed(codex.adapter)
...@@ -444,8 +465,11 @@ it.effect(...@@ -444,8 +465,11 @@ it.effect(
444 PubSub.subscribe(pubsub),465 PubSub.subscribe(pubsub),
445 ),466 ),
446 };467 };
447 const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry);468 const providerAdapterLayer = Layer.succeed(
448 const serverSettingsLayer = ServerSettingsService.layerTest({469 ProviderAdapterRegistry.ProviderAdapterRegistry,
470 registry,
471 );
472 const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest({
449 providers: {473 providers: {
450 codex: {474 codex: {
451 enabled: false,475 enabled: false,
...@@ -463,11 +487,16 @@ it.effect(...@@ -463,11 +487,16 @@ it.effect(
463 Layer.provide(directoryLayer),487 Layer.provide(directoryLayer),
464 Layer.provide(serverSettingsLayer),488 Layer.provide(serverSettingsLayer),
465 Layer.provide(AnalyticsService.layerTest),489 Layer.provide(AnalyticsService.layerTest),
466 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),490 Layer.provide(
491 Layer.succeed(
492 ProviderEventLoggers.ProviderEventLoggers,
493 ProviderEventLoggers.NoOpProviderEventLoggers,
494 ),
495 ),
467 );496 );
468497
469 const session = yield* Effect.gen(function* () {498 const session = yield* Effect.gen(function* () {
470 const provider = yield* ProviderService;499 const provider = yield* ProviderService.ProviderService;
471 return yield* provider.startSession(asThreadId("thread-enabled-custom"), {500 return yield* provider.startSession(asThreadId("thread-enabled-custom"), {
472 provider: driverKind,501 provider: driverKind,
473 providerInstanceId: instanceId,502 providerInstanceId: instanceId,
...@@ -490,7 +519,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance...@@ -490,7 +519,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance
490 new ProviderUnsupportedError({519 new ProviderUnsupportedError({
491 provider: ProviderDriverKind.make("codex"),520 provider: ProviderDriverKind.make("codex"),
492 });521 });
493 const registry: ProviderAdapterRegistryShape = {522 const registry: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"] = {
494 getByInstance: (requestedInstanceId) =>523 getByInstance: (requestedInstanceId) =>
495 requestedInstanceId === instanceId524 requestedInstanceId === instanceId
496 ? Effect.succeed(codex.adapter)525 ? Effect.succeed(codex.adapter)
...@@ -515,7 +544,10 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance...@@ -515,7 +544,10 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance
515 PubSub.subscribe(pubsub),544 PubSub.subscribe(pubsub),
516 ),545 ),
517 };546 };
518 const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry);547 const providerAdapterLayer = Layer.succeed(
548 ProviderAdapterRegistry.ProviderAdapterRegistry,
549 registry,
550 );
519 const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(551 const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(
520 Layer.provide(SqlitePersistenceMemory),552 Layer.provide(SqlitePersistenceMemory),
521 );553 );
...@@ -525,12 +557,17 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance...@@ -525,12 +557,17 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance
525 Layer.provide(directoryLayer),557 Layer.provide(directoryLayer),
526 Layer.provide(defaultServerSettingsLayer),558 Layer.provide(defaultServerSettingsLayer),
527 Layer.provide(AnalyticsService.layerTest),559 Layer.provide(AnalyticsService.layerTest),
528 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),560 Layer.provide(
561 Layer.succeed(
562 ProviderEventLoggers.ProviderEventLoggers,
563 ProviderEventLoggers.NoOpProviderEventLoggers,
564 ),
565 ),
529 );566 );
530567
531 const failure = yield* Effect.flip(568 const failure = yield* Effect.flip(
532 Effect.gen(function* () {569 Effect.gen(function* () {
533 const provider = yield* ProviderService;570 const provider = yield* ProviderService.ProviderService;
534 return yield* provider.startSession(asThreadId("thread-disabled-instance"), {571 return yield* provider.startSession(asThreadId("thread-disabled-instance"), {
535 provider: ProviderDriverKind.make("codex"),572 provider: ProviderDriverKind.make("codex"),
536 providerInstanceId: instanceId,573 providerInstanceId: instanceId,
...@@ -571,15 +608,20 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se...@@ -571,15 +608,20 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se
571 close: () => Effect.void,608 close: () => Effect.void,
572 },609 },
573 }).pipe(610 }).pipe(
574 Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)),611 Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)),
575 Layer.provide(directoryLayer),612 Layer.provide(directoryLayer),
576 Layer.provide(defaultServerSettingsLayer),613 Layer.provide(defaultServerSettingsLayer),
577 Layer.provide(AnalyticsService.layerTest),614 Layer.provide(AnalyticsService.layerTest),
578 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),615 Layer.provide(
616 Layer.succeed(
617 ProviderEventLoggers.ProviderEventLoggers,
618 ProviderEventLoggers.NoOpProviderEventLoggers,
619 ),
620 ),
579 );621 );
580622
581 yield* Effect.gen(function* () {623 yield* Effect.gen(function* () {
582 yield* ProviderService;624 yield* ProviderService.ProviderService;
583 yield* advanceTestClock(10);625 yield* advanceTestClock(10);
584 codex.emit({626 codex.emit({
585 eventId: asEventId("evt-canonical-thread-segment"),627 eventId: asEventId("evt-canonical-thread-segment"),
...@@ -617,7 +659,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (...@@ -617,7 +659,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (
617 const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer));659 const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer));
618660
619 yield* Effect.gen(function* () {661 yield* Effect.gen(function* () {
620 const directory = yield* ProviderSessionDirectory;662 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
621 yield* directory.upsert({663 yield* directory.upsert({
622 provider: ProviderDriverKind.make("codex"),664 provider: ProviderDriverKind.make("codex"),
623 providerInstanceId: codexInstanceId,665 providerInstanceId: codexInstanceId,
...@@ -626,17 +668,22 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (...@@ -626,17 +668,22 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (
626 }).pipe(Effect.provide(directoryLayer));668 }).pipe(Effect.provide(directoryLayer));
627669
628 const providerLayer = makeProviderServiceLive().pipe(670 const providerLayer = makeProviderServiceLive().pipe(
629 Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)),671 Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)),
630 Layer.provide(directoryLayer),672 Layer.provide(directoryLayer),
631 Layer.provide(defaultServerSettingsLayer),673 Layer.provide(defaultServerSettingsLayer),
632 Layer.provide(AnalyticsService.layerTest),674 Layer.provide(AnalyticsService.layerTest),
633 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),675 Layer.provide(
676 Layer.succeed(
677 ProviderEventLoggers.ProviderEventLoggers,
678 ProviderEventLoggers.NoOpProviderEventLoggers,
679 ),
680 ),
634 );681 );
635682
636 yield* ProviderService.pipe(Effect.provide(providerLayer));683 yield* ProviderService.ProviderService.pipe(Effect.provide(providerLayer));
637684
638 const persistedProvider = yield* Effect.gen(function* () {685 const persistedProvider = yield* Effect.gen(function* () {
639 const directory = yield* ProviderSessionDirectory;686 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
640 return yield* directory.getProvider(asThreadId("thread-stale"));687 return yield* directory.getProvider(asThreadId("thread-stale"));
641 }).pipe(Effect.provide(directoryLayer));688 }).pipe(Effect.provide(directoryLayer));
642 assert.equal(persistedProvider, "codex");689 assert.equal(persistedProvider, "codex");
...@@ -683,11 +730,18 @@ it.effect(...@@ -683,11 +730,18 @@ it.effect(
683 Layer.provide(runtimeRepositoryLayer),730 Layer.provide(runtimeRepositoryLayer),
684 );731 );
685 const firstProviderLayer = makeProviderServiceLive().pipe(732 const firstProviderLayer = makeProviderServiceLive().pipe(
686 Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)),733 Layer.provide(
734 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry),
735 ),
687 Layer.provide(firstDirectoryLayer),736 Layer.provide(firstDirectoryLayer),
688 Layer.provide(defaultServerSettingsLayer),737 Layer.provide(defaultServerSettingsLayer),
689 Layer.provide(AnalyticsService.layerTest),738 Layer.provide(AnalyticsService.layerTest),
690 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),739 Layer.provide(
740 Layer.succeed(
741 ProviderEventLoggers.ProviderEventLoggers,
742 ProviderEventLoggers.NoOpProviderEventLoggers,
743 ),
744 ),
691 );745 );
692 const updatedResumeCursor = {746 const updatedResumeCursor = {
693 threadId: asThreadId("thread-1"),747 threadId: asThreadId("thread-1"),
...@@ -697,7 +751,7 @@ it.effect(...@@ -697,7 +751,7 @@ it.effect(
697 };751 };
698752
699 const startedSession = yield* Effect.gen(function* () {753 const startedSession = yield* Effect.gen(function* () {
700 const provider = yield* ProviderService;754 const provider = yield* ProviderService.ProviderService;
701 const threadId = asThreadId("thread-1");755 const threadId = asThreadId("thread-1");
702 const session = yield* provider.startSession(threadId, {756 const session = yield* provider.startSession(threadId, {
703 provider: ProviderDriverKind.make("codex"),757 provider: ProviderDriverKind.make("codex"),
...@@ -735,18 +789,25 @@ it.effect(...@@ -735,18 +789,25 @@ it.effect(
735 Layer.provide(runtimeRepositoryLayer),789 Layer.provide(runtimeRepositoryLayer),
736 );790 );
737 const secondProviderLayer = makeProviderServiceLive().pipe(791 const secondProviderLayer = makeProviderServiceLive().pipe(
738 Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)),792 Layer.provide(
793 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry),
794 ),
739 Layer.provide(secondDirectoryLayer),795 Layer.provide(secondDirectoryLayer),
740 Layer.provide(defaultServerSettingsLayer),796 Layer.provide(defaultServerSettingsLayer),
741 Layer.provide(AnalyticsService.layerTest),797 Layer.provide(AnalyticsService.layerTest),
742 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),798 Layer.provide(
799 Layer.succeed(
800 ProviderEventLoggers.ProviderEventLoggers,
801 ProviderEventLoggers.NoOpProviderEventLoggers,
802 ),
803 ),
743 );804 );
744805
745 secondCodex.startSession.mockClear();806 secondCodex.startSession.mockClear();
746 secondCodex.rollbackThread.mockClear();807 secondCodex.rollbackThread.mockClear();
747808
748 yield* Effect.gen(function* () {809 yield* Effect.gen(function* () {
749 const provider = yield* ProviderService;810 const provider = yield* ProviderService.ProviderService;
750 yield* provider.rollbackConversation({811 yield* provider.rollbackConversation({
751 threadId: startedSession.threadId,812 threadId: startedSession.threadId,
752 numTurns: 1,813 numTurns: 1,
...@@ -780,7 +841,7 @@ it.effect(...@@ -780,7 +841,7 @@ it.effect(
780routing.layer("ProviderServiceLive routing", (it) => {841routing.layer("ProviderServiceLive routing", (it) => {
781 it.effect("routes provider operations and rollback conversation", () =>842 it.effect("routes provider operations and rollback conversation", () =>
782 Effect.gen(function* () {843 Effect.gen(function* () {
783 const provider = yield* ProviderService;844 const provider = yield* ProviderService.ProviderService;
784845
785 const session = yield* provider.startSession(asThreadId("thread-1"), {846 const session = yield* provider.startSession(asThreadId("thread-1"), {
786 provider: ProviderDriverKind.make("codex"),847 provider: ProviderDriverKind.make("codex"),
...@@ -866,7 +927,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -866,7 +927,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
866927
867 it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () =>928 it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () =>
868 Effect.gen(function* () {929 Effect.gen(function* () {
869 const provider = yield* ProviderService;930 const provider = yield* ProviderService.ProviderService;
870931
871 const initial = yield* provider.startSession(asThreadId("thread-1"), {932 const initial = yield* provider.startSession(asThreadId("thread-1"), {
872 provider: ProviderDriverKind.make("codex"),933 provider: ProviderDriverKind.make("codex"),
...@@ -907,7 +968,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -907,7 +968,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
907968
908 it.effect("preserves the persisted binding when stopping a session", () =>969 it.effect("preserves the persisted binding when stopping a session", () =>
909 Effect.gen(function* () {970 Effect.gen(function* () {
910 const provider = yield* ProviderService;971 const provider = yield* ProviderService.ProviderService;
911 const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;972 const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
912973
913 const initial = yield* provider.startSession(asThreadId("thread-reap-preserve"), {974 const initial = yield* provider.startSession(asThreadId("thread-reap-preserve"), {
...@@ -959,7 +1020,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -959,7 +1020,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
9591020
960 it.effect("routes explicit claudeAgent provider session starts to the claude adapter", () =>1021 it.effect("routes explicit claudeAgent provider session starts to the claude adapter", () =>
961 Effect.gen(function* () {1022 Effect.gen(function* () {
962 const provider = yield* ProviderService;1023 const provider = yield* ProviderService.ProviderService;
9631024
964 const session = yield* provider.startSession(asThreadId("thread-claude"), {1025 const session = yield* provider.startSession(asThreadId("thread-claude"), {
965 provider: ProviderDriverKind.make("claudeAgent"),1026 provider: ProviderDriverKind.make("claudeAgent"),
...@@ -988,8 +1049,8 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -988,8 +1049,8 @@ routing.layer("ProviderServiceLive routing", (it) => {
9881049
989 it.effect("dies when an active session conflicts with its persisted binding", () =>1050 it.effect("dies when an active session conflicts with its persisted binding", () =>
990 Effect.gen(function* () {1051 Effect.gen(function* () {
991 const provider = yield* ProviderService;1052 const provider = yield* ProviderService.ProviderService;
992 const directory = yield* ProviderSessionDirectory;1053 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
993 const threadId = asThreadId("thread-binding-mismatch");1054 const threadId = asThreadId("thread-binding-mismatch");
9941055
995 yield* provider.startSession(threadId, {1056 yield* provider.startSession(threadId, {
...@@ -1019,7 +1080,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1019,7 +1080,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
10191080
1020 it.effect("stops stale sessions in other providers after a successful replacement start", () =>1081 it.effect("stops stale sessions in other providers after a successful replacement start", () =>
1021 Effect.gen(function* () {1082 Effect.gen(function* () {
1022 const provider = yield* ProviderService;1083 const provider = yield* ProviderService.ProviderService;
1023 const threadId = asThreadId("thread-provider-replacement");1084 const threadId = asThreadId("thread-provider-replacement");
10241085
1025 const codexSession = yield* provider.startSession(threadId, {1086 const codexSession = yield* provider.startSession(threadId, {
...@@ -1058,7 +1119,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1058,7 +1119,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
10581119
1059 it.effect("recovers stale sessions for sendTurn using persisted cwd", () =>1120 it.effect("recovers stale sessions for sendTurn using persisted cwd", () =>
1060 Effect.gen(function* () {1121 Effect.gen(function* () {
1061 const provider = yield* ProviderService;1122 const provider = yield* ProviderService.ProviderService;
10621123
1063 const initial = yield* provider.startSession(asThreadId("thread-1"), {1124 const initial = yield* provider.startSession(asThreadId("thread-1"), {
1064 provider: ProviderDriverKind.make("codex"),1125 provider: ProviderDriverKind.make("codex"),
...@@ -1099,7 +1160,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1099,7 +1160,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
10991160
1100 it.effect("recovers stale claudeAgent sessions for sendTurn using persisted cwd", () =>1161 it.effect("recovers stale claudeAgent sessions for sendTurn using persisted cwd", () =>
1101 Effect.gen(function* () {1162 Effect.gen(function* () {
1102 const provider = yield* ProviderService;1163 const provider = yield* ProviderService.ProviderService;
11031164
1104 const initial = yield* provider.startSession(asThreadId("thread-claude-send-turn"), {1165 const initial = yield* provider.startSession(asThreadId("thread-claude-send-turn"), {
1105 provider: ProviderDriverKind.make("claudeAgent"),1166 provider: ProviderDriverKind.make("claudeAgent"),
...@@ -1152,7 +1213,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1152,7 +1213,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
11521213
1153 it.effect("lists no sessions after adapter runtime clears", () =>1214 it.effect("lists no sessions after adapter runtime clears", () =>
1154 Effect.gen(function* () {1215 Effect.gen(function* () {
1155 const provider = yield* ProviderService;1216 const provider = yield* ProviderService.ProviderService;
11561217
1157 yield* provider.startSession(asThreadId("thread-1"), {1218 yield* provider.startSession(asThreadId("thread-1"), {
1158 provider: ProviderDriverKind.make("codex"),1219 provider: ProviderDriverKind.make("codex"),
...@@ -1177,7 +1238,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1177,7 +1238,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
11771238
1178 it.effect("persists runtime status transitions in provider_session_runtime", () =>1239 it.effect("persists runtime status transitions in provider_session_runtime", () =>
1179 Effect.gen(function* () {1240 Effect.gen(function* () {
1180 const provider = yield* ProviderService;1241 const provider = yield* ProviderService.ProviderService;
1181 const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;1242 const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
11821243
1183 const threadId = asThreadId("thread-runtime-status");1244 const threadId = asThreadId("thread-runtime-status");
...@@ -1237,15 +1298,22 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1237,15 +1298,22 @@ routing.layer("ProviderServiceLive routing", (it) => {
1237 Layer.provide(runtimeRepositoryLayer),1298 Layer.provide(runtimeRepositoryLayer),
1238 );1299 );
1239 const firstProviderLayer = makeProviderServiceLive().pipe(1300 const firstProviderLayer = makeProviderServiceLive().pipe(
1240 Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)),1301 Layer.provide(
1302 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry),
1303 ),
1241 Layer.provide(firstDirectoryLayer),1304 Layer.provide(firstDirectoryLayer),
1242 Layer.provide(defaultServerSettingsLayer),1305 Layer.provide(defaultServerSettingsLayer),
1243 Layer.provide(AnalyticsService.layerTest),1306 Layer.provide(AnalyticsService.layerTest),
1244 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),1307 Layer.provide(
1308 Layer.succeed(
1309 ProviderEventLoggers.ProviderEventLoggers,
1310 ProviderEventLoggers.NoOpProviderEventLoggers,
1311 ),
1312 ),
1245 );1313 );
12461314
1247 const initial = yield* Effect.gen(function* () {1315 const initial = yield* Effect.gen(function* () {
1248 const provider = yield* ProviderService;1316 const provider = yield* ProviderService.ProviderService;
1249 return yield* provider.startSession(asThreadId("thread-claude-start"), {1317 return yield* provider.startSession(asThreadId("thread-claude-start"), {
1250 provider: ProviderDriverKind.make("claudeAgent"),1318 provider: ProviderDriverKind.make("claudeAgent"),
1251 providerInstanceId: claudeAgentInstanceId,1319 providerInstanceId: claudeAgentInstanceId,
...@@ -1256,7 +1324,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1256,7 +1324,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
1256 }).pipe(Effect.provide(firstProviderLayer));1324 }).pipe(Effect.provide(firstProviderLayer));
12571325
1258 yield* Effect.gen(function* () {1326 yield* Effect.gen(function* () {
1259 const provider = yield* ProviderService;1327 const provider = yield* ProviderService.ProviderService;
1260 yield* provider.listSessions();1328 yield* provider.listSessions();
1261 }).pipe(Effect.provide(firstProviderLayer));1329 }).pipe(Effect.provide(firstProviderLayer));
12621330
...@@ -1268,17 +1336,24 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1268,17 +1336,24 @@ routing.layer("ProviderServiceLive routing", (it) => {
1268 Layer.provide(runtimeRepositoryLayer),1336 Layer.provide(runtimeRepositoryLayer),
1269 );1337 );
1270 const secondProviderLayer = makeProviderServiceLive().pipe(1338 const secondProviderLayer = makeProviderServiceLive().pipe(
1271 Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)),1339 Layer.provide(
1340 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry),
1341 ),
1272 Layer.provide(secondDirectoryLayer),1342 Layer.provide(secondDirectoryLayer),
1273 Layer.provide(defaultServerSettingsLayer),1343 Layer.provide(defaultServerSettingsLayer),
1274 Layer.provide(AnalyticsService.layerTest),1344 Layer.provide(AnalyticsService.layerTest),
1275 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),1345 Layer.provide(
1346 Layer.succeed(
1347 ProviderEventLoggers.ProviderEventLoggers,
1348 ProviderEventLoggers.NoOpProviderEventLoggers,
1349 ),
1350 ),
1276 );1351 );
12771352
1278 secondClaude.startSession.mockClear();1353 secondClaude.startSession.mockClear();
12791354
1280 yield* Effect.gen(function* () {1355 yield* Effect.gen(function* () {
1281 const provider = yield* ProviderService;1356 const provider = yield* ProviderService.ProviderService;
1282 yield* provider.startSession(initial.threadId, {1357 yield* provider.startSession(initial.threadId, {
1283 provider: ProviderDriverKind.make("claudeAgent"),1358 provider: ProviderDriverKind.make("claudeAgent"),
1284 providerInstanceId: claudeAgentInstanceId,1359 providerInstanceId: claudeAgentInstanceId,
...@@ -1327,15 +1402,22 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1327,15 +1402,22 @@ routing.layer("ProviderServiceLive routing", (it) => {
1327 Layer.provide(runtimeRepositoryLayer),1402 Layer.provide(runtimeRepositoryLayer),
1328 );1403 );
1329 const firstProviderLayer = makeProviderServiceLive().pipe(1404 const firstProviderLayer = makeProviderServiceLive().pipe(
1330 Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)),1405 Layer.provide(
1406 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry),
1407 ),
1331 Layer.provide(firstDirectoryLayer),1408 Layer.provide(firstDirectoryLayer),
1332 Layer.provide(defaultServerSettingsLayer),1409 Layer.provide(defaultServerSettingsLayer),
1333 Layer.provide(AnalyticsService.layerTest),1410 Layer.provide(AnalyticsService.layerTest),
1334 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),1411 Layer.provide(
1412 Layer.succeed(
1413 ProviderEventLoggers.ProviderEventLoggers,
1414 ProviderEventLoggers.NoOpProviderEventLoggers,
1415 ),
1416 ),
1335 );1417 );
13361418
1337 const initial = yield* Effect.gen(function* () {1419 const initial = yield* Effect.gen(function* () {
1338 const provider = yield* ProviderService;1420 const provider = yield* ProviderService.ProviderService;
1339 return yield* provider.startSession(asThreadId("thread-claude-cwd"), {1421 return yield* provider.startSession(asThreadId("thread-claude-cwd"), {
1340 provider: ProviderDriverKind.make("claudeAgent"),1422 provider: ProviderDriverKind.make("claudeAgent"),
1341 providerInstanceId: claudeAgentInstanceId,1423 providerInstanceId: claudeAgentInstanceId,
...@@ -1353,17 +1435,24 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1353,17 +1435,24 @@ routing.layer("ProviderServiceLive routing", (it) => {
1353 Layer.provide(runtimeRepositoryLayer),1435 Layer.provide(runtimeRepositoryLayer),
1354 );1436 );
1355 const secondProviderLayer = makeProviderServiceLive().pipe(1437 const secondProviderLayer = makeProviderServiceLive().pipe(
1356 Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)),1438 Layer.provide(
1439 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry),
1440 ),
1357 Layer.provide(secondDirectoryLayer),1441 Layer.provide(secondDirectoryLayer),
1358 Layer.provide(defaultServerSettingsLayer),1442 Layer.provide(defaultServerSettingsLayer),
1359 Layer.provide(AnalyticsService.layerTest),1443 Layer.provide(AnalyticsService.layerTest),
1360 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),1444 Layer.provide(
1445 Layer.succeed(
1446 ProviderEventLoggers.ProviderEventLoggers,
1447 ProviderEventLoggers.NoOpProviderEventLoggers,
1448 ),
1449 ),
1361 );1450 );
13621451
1363 secondClaude.startSession.mockClear();1452 secondClaude.startSession.mockClear();
13641453
1365 yield* Effect.gen(function* () {1454 yield* Effect.gen(function* () {
1366 const provider = yield* ProviderService;1455 const provider = yield* ProviderService.ProviderService;
1367 yield* provider.startSession(initial.threadId, {1456 yield* provider.startSession(initial.threadId, {
1368 provider: ProviderDriverKind.make("claudeAgent"),1457 provider: ProviderDriverKind.make("claudeAgent"),
1369 providerInstanceId: claudeAgentInstanceId,1458 providerInstanceId: claudeAgentInstanceId,
...@@ -1397,7 +1486,7 @@ const fanout = makeProviderServiceLayer();...@@ -1397,7 +1486,7 @@ const fanout = makeProviderServiceLayer();
1397fanout.layer("ProviderServiceLive fanout", (it) => {1486fanout.layer("ProviderServiceLive fanout", (it) => {
1398 it.effect("fans out adapter turn completion events", () =>1487 it.effect("fans out adapter turn completion events", () =>
1399 Effect.gen(function* () {1488 Effect.gen(function* () {
1400 const provider = yield* ProviderService;1489 const provider = yield* ProviderService.ProviderService;
1401 const session = yield* provider.startSession(asThreadId("thread-1"), {1490 const session = yield* provider.startSession(asThreadId("thread-1"), {
1402 provider: ProviderDriverKind.make("codex"),1491 provider: ProviderDriverKind.make("codex"),
1403 providerInstanceId: codexInstanceId,1492 providerInstanceId: codexInstanceId,
...@@ -1443,7 +1532,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -1443,7 +1532,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
14431532
1444 it.effect("fans out canonical runtime events in emission order", () =>1533 it.effect("fans out canonical runtime events in emission order", () =>
1445 Effect.gen(function* () {1534 Effect.gen(function* () {
1446 const provider = yield* ProviderService;1535 const provider = yield* ProviderService.ProviderService;
1447 const session = yield* provider.startSession(asThreadId("thread-seq"), {1536 const session = yield* provider.startSession(asThreadId("thread-seq"), {
1448 provider: ProviderDriverKind.make("codex"),1537 provider: ProviderDriverKind.make("codex"),
1449 providerInstanceId: codexInstanceId,1538 providerInstanceId: codexInstanceId,
...@@ -1499,7 +1588,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -1499,7 +1588,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
14991588
1500 it.effect("keeps subscriber delivery ordered and isolates failing subscribers", () =>1589 it.effect("keeps subscriber delivery ordered and isolates failing subscribers", () =>
1501 Effect.gen(function* () {1590 Effect.gen(function* () {
1502 const provider = yield* ProviderService;1591 const provider = yield* ProviderService.ProviderService;
1503 const session = yield* provider.startSession(asThreadId("thread-1"), {1592 const session = yield* provider.startSession(asThreadId("thread-1"), {
1504 provider: ProviderDriverKind.make("codex"),1593 provider: ProviderDriverKind.make("codex"),
1505 providerInstanceId: codexInstanceId,1594 providerInstanceId: codexInstanceId,
...@@ -1571,7 +1660,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -1571,7 +1660,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
15711660
1572 it.effect("records provider metrics with the routed provider label", () =>1661 it.effect("records provider metrics with the routed provider label", () =>
1573 Effect.gen(function* () {1662 Effect.gen(function* () {
1574 const provider = yield* ProviderService;1663 const provider = yield* ProviderService.ProviderService;
15751664
1576 const session = yield* provider.startSession(asThreadId("thread-metrics"), {1665 const session = yield* provider.startSession(asThreadId("thread-metrics"), {
1577 provider: ProviderDriverKind.make("claudeAgent"),1666 provider: ProviderDriverKind.make("claudeAgent"),
...@@ -1649,7 +1738,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -1649,7 +1738,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
1649 "records sendTurn metrics with the resolved provider when modelSelection is omitted",1738 "records sendTurn metrics with the resolved provider when modelSelection is omitted",
1650 () =>1739 () =>
1651 Effect.gen(function* () {1740 Effect.gen(function* () {
1652 const provider = yield* ProviderService;1741 const provider = yield* ProviderService.ProviderService;
16531742
1654 const session = yield* provider.startSession(asThreadId("thread-send-metrics"), {1743 const session = yield* provider.startSession(asThreadId("thread-send-metrics"), {
1655 provider: ProviderDriverKind.make("claudeAgent"),1744 provider: ProviderDriverKind.make("claudeAgent"),
...@@ -1690,7 +1779,7 @@ const validation = makeProviderServiceLayer();...@@ -1690,7 +1779,7 @@ const validation = makeProviderServiceLayer();
1690validation.layer("ProviderServiceLive validation", (it) => {1779validation.layer("ProviderServiceLive validation", (it) => {
1691 it.effect("rejects session starts without an explicit provider instance id", () =>1780 it.effect("rejects session starts without an explicit provider instance id", () =>
1692 Effect.gen(function* () {1781 Effect.gen(function* () {
1693 const provider = yield* ProviderService;1782 const provider = yield* ProviderService.ProviderService;
16941783
1695 validation.codex.startSession.mockClear();1784 validation.codex.startSession.mockClear();
1696 const failure = yield* Effect.flip(1785 const failure = yield* Effect.flip(
...@@ -1709,7 +1798,7 @@ validation.layer("ProviderServiceLive validation", (it) => {...@@ -1709,7 +1798,7 @@ validation.layer("ProviderServiceLive validation", (it) => {
17091798
1710 it.effect("rejects mismatched provider kind and provider instance id", () =>1799 it.effect("rejects mismatched provider kind and provider instance id", () =>
1711 Effect.gen(function* () {1800 Effect.gen(function* () {
1712 const provider = yield* ProviderService;1801 const provider = yield* ProviderService.ProviderService;
17131802
1714 validation.codex.startSession.mockClear();1803 validation.codex.startSession.mockClear();
1715 validation.claude.startSession.mockClear();1804 validation.claude.startSession.mockClear();
...@@ -1734,7 +1823,7 @@ validation.layer("ProviderServiceLive validation", (it) => {...@@ -1734,7 +1823,7 @@ validation.layer("ProviderServiceLive validation", (it) => {
17341823
1735 it.effect("returns ProviderValidationError for invalid input payloads", () =>1824 it.effect("returns ProviderValidationError for invalid input payloads", () =>
1736 Effect.gen(function* () {1825 Effect.gen(function* () {
1737 const provider = yield* ProviderService;1826 const provider = yield* ProviderService.ProviderService;
17381827
1739 const failure = yield* Effect.result(1828 const failure = yield* Effect.result(
1740 provider.startSession(asThreadId("thread-validation"), {1829 provider.startSession(asThreadId("thread-validation"), {
...@@ -1759,7 +1848,7 @@ validation.layer("ProviderServiceLive validation", (it) => {...@@ -1759,7 +1848,7 @@ validation.layer("ProviderServiceLive validation", (it) => {
17591848
1760 it.effect("accepts startSession when adapter has not emitted provider thread id yet", () =>1849 it.effect("accepts startSession when adapter has not emitted provider thread id yet", () =>
1761 Effect.gen(function* () {1850 Effect.gen(function* () {
1762 const provider = yield* ProviderService;1851 const provider = yield* ProviderService.ProviderService;
1763 const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;1852 const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
17641853
1765 validation.codex.startSession.mockImplementationOnce((input: ProviderSessionStartInput) =>1854 validation.codex.startSession.mockImplementationOnce((input: ProviderSessionStartInput) =>
apps/server/src/provider/Layers/ProviderService.ts+44-32
...@@ -47,15 +47,12 @@ import {...@@ -47,15 +47,12 @@ import {
47} from "../../observability/Metrics.ts";47} from "../../observability/Metrics.ts";
48import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts";48import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts";
49import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";49import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
50import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";50import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts";
51import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts";51import * as ProviderService from "../Services/ProviderService.ts";
52import {52import * as ProviderSessionDirectory from "../Services/ProviderSessionDirectory.ts";
53 ProviderSessionDirectory,
54 type ProviderRuntimeBinding,
55} from "../Services/ProviderSessionDirectory.ts";
56import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts";53import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts";
57import { ProviderEventLoggers } from "./ProviderEventLoggers.ts";54import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";
58import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts";55import * as AnalyticsService from "../../telemetry/AnalyticsService.ts";
59import * as McpProviderSession from "../../mcp/McpProviderSession.ts";56import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
60import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts";57import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts";
61const isModelSelection = Schema.is(ModelSelection);58const isModelSelection = Schema.is(ModelSelection);
...@@ -69,6 +66,9 @@ export interface ProviderServiceLiveOptions {...@@ -69,6 +66,9 @@ export interface ProviderServiceLiveOptions {
69 readonly canonicalEventLogger?: EventNdjsonLogger;66 readonly canonicalEventLogger?: EventNdjsonLogger;
70}67}
7168
69type ProviderServiceMethod<Name extends keyof ProviderService.ProviderService["Service"]> =
70 ProviderService.ProviderService["Service"][Name];
71
72const ProviderRollbackConversationInput = Schema.Struct({72const ProviderRollbackConversationInput = Schema.Struct({
73 threadId: ThreadId,73 threadId: ThreadId,
74 numTurns: NonNegativeInt,74 numTurns: NonNegativeInt,
...@@ -141,7 +141,7 @@ function toRuntimePayloadFromSession(...@@ -141,7 +141,7 @@ function toRuntimePayloadFromSession(
141}141}
142142
143function readPersistedModelSelection(143function readPersistedModelSelection(
144 runtimePayload: ProviderRuntimeBinding["runtimePayload"],144 runtimePayload: ProviderSessionDirectory.ProviderRuntimeBinding["runtimePayload"],
145): ModelSelection | undefined {145): ModelSelection | undefined {
146 if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) {146 if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) {
147 return undefined;147 return undefined;
...@@ -151,7 +151,7 @@ function readPersistedModelSelection(...@@ -151,7 +151,7 @@ function readPersistedModelSelection(
151}151}
152152
153function readPersistedCwd(153function readPersistedCwd(
154 runtimePayload: ProviderRuntimeBinding["runtimePayload"],154 runtimePayload: ProviderSessionDirectory.ProviderRuntimeBinding["runtimePayload"],
155): string | undefined {155): string | undefined {
156 if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) {156 if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) {
157 return undefined;157 return undefined;
...@@ -202,16 +202,16 @@ const correlateRuntimeEventWithInstance = (...@@ -202,16 +202,16 @@ const correlateRuntimeEventWithInstance = (
202const makeProviderService = Effect.fn("makeProviderService")(function* (202const makeProviderService = Effect.fn("makeProviderService")(function* (
203 options?: ProviderServiceLiveOptions,203 options?: ProviderServiceLiveOptions,
204) {204) {
205 const analytics = yield* Effect.service(AnalyticsService);205 const analytics = yield* Effect.service(AnalyticsService.AnalyticsService);
206 const eventLoggers = yield* ProviderEventLoggers;206 const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers;
207 // Options-provided logger wins (test overrides); otherwise we take whatever207 // Options-provided logger wins (test overrides); otherwise we take whatever
208 // the `ProviderEventLoggers` tag exposes — `undefined` means "no canonical208 // the `ProviderEventLoggers` tag exposes — `undefined` means "no canonical
209 // log writer is attached", which downstream code already handles as a209 // log writer is attached", which downstream code already handles as a
210 // no-op.210 // no-op.
211 const canonicalEventLogger = options?.canonicalEventLogger ?? eventLoggers.canonical;211 const canonicalEventLogger = options?.canonicalEventLogger ?? eventLoggers.canonical;
212212
213 const registry = yield* ProviderAdapterRegistry;213 const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry;
214 const directory = yield* ProviderSessionDirectory;214 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
215 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();215 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();
216 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);216 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
217 const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) =>217 const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) =>
...@@ -353,7 +353,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -353,7 +353,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
353 ).pipe(Effect.forkScoped);353 ).pipe(Effect.forkScoped);
354354
355 const recoverSessionForThread = Effect.fn("recoverSessionForThread")(function* (input: {355 const recoverSessionForThread = Effect.fn("recoverSessionForThread")(function* (input: {
356 readonly binding: ProviderRuntimeBinding;356 readonly binding: ProviderSessionDirectory.ProviderRuntimeBinding;
357 readonly operation: string;357 readonly operation: string;
358 }) {358 }) {
359 const bindingInstanceId = yield* requireBindingInstanceId(input.operation, input.binding);359 const bindingInstanceId = yield* requireBindingInstanceId(input.operation, input.binding);
...@@ -519,7 +519,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -519,7 +519,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
519 );519 );
520 });520 });
521521
522 const startSession: ProviderServiceShape["startSession"] = Effect.fn("startSession")(522 const startSession: ProviderServiceMethod<"startSession"> = Effect.fn("startSession")(
523 function* (threadId, rawInput) {523 function* (threadId, rawInput) {
524 const parsed = yield* decodeInputOrValidationError({524 const parsed = yield* decodeInputOrValidationError({
525 operation: "ProviderService.startSession",525 operation: "ProviderService.startSession",
...@@ -642,7 +642,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -642,7 +642,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
642 },642 },
643 );643 );
644644
645 const sendTurn: ProviderServiceShape["sendTurn"] = Effect.fn("sendTurn")(function* (rawInput) {645 const sendTurn: ProviderServiceMethod<"sendTurn"> = Effect.fn("sendTurn")(function* (rawInput) {
646 const parsed = yield* decodeInputOrValidationError({646 const parsed = yield* decodeInputOrValidationError({
647 operation: "ProviderService.sendTurn",647 operation: "ProviderService.sendTurn",
648 schema: ProviderSendTurnInput,648 schema: ProviderSendTurnInput,
...@@ -717,7 +717,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -717,7 +717,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
717 );717 );
718 });718 });
719719
720 const interruptTurn: ProviderServiceShape["interruptTurn"] = Effect.fn("interruptTurn")(720 const interruptTurn: ProviderServiceMethod<"interruptTurn"> = Effect.fn("interruptTurn")(
721 function* (rawInput) {721 function* (rawInput) {
722 const input = yield* decodeInputOrValidationError({722 const input = yield* decodeInputOrValidationError({
723 operation: "ProviderService.interruptTurn",723 operation: "ProviderService.interruptTurn",
...@@ -754,7 +754,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -754,7 +754,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
754 },754 },
755 );755 );
756756
757 const respondToRequest: ProviderServiceShape["respondToRequest"] = Effect.fn("respondToRequest")(757 const respondToRequest: ProviderServiceMethod<"respondToRequest"> = Effect.fn("respondToRequest")(
758 function* (rawInput) {758 function* (rawInput) {
759 const input = yield* decodeInputOrValidationError({759 const input = yield* decodeInputOrValidationError({
760 operation: "ProviderService.respondToRequest",760 operation: "ProviderService.respondToRequest",
...@@ -792,7 +792,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -792,7 +792,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
792 },792 },
793 );793 );
794794
795 const respondToUserInput: ProviderServiceShape["respondToUserInput"] = Effect.fn(795 const respondToUserInput: ProviderServiceMethod<"respondToUserInput"> = Effect.fn(
796 "respondToUserInput",796 "respondToUserInput",
797 )(function* (rawInput) {797 )(function* (rawInput) {
798 const input = yield* decodeInputOrValidationError({798 const input = yield* decodeInputOrValidationError({
...@@ -826,7 +826,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -826,7 +826,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
826 );826 );
827 });827 });
828828
829 const stopSession: ProviderServiceShape["stopSession"] = Effect.fn("stopSession")(829 const stopSession: ProviderServiceMethod<"stopSession"> = Effect.fn("stopSession")(
830 function* (rawInput) {830 function* (rawInput) {
831 const input = yield* decodeInputOrValidationError({831 const input = yield* decodeInputOrValidationError({
832 operation: "ProviderService.stopSession",832 operation: "ProviderService.stopSession",
...@@ -874,7 +874,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -874,7 +874,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
874 },874 },
875 );875 );
876876
877 const listSessions: ProviderServiceShape["listSessions"] = Effect.fn("listSessions")(877 const listSessions: ProviderServiceMethod<"listSessions"> = Effect.fn("listSessions")(
878 function* () {878 function* () {
879 const currentAdapters = yield* getAdapterEntries;879 const currentAdapters = yield* getAdapterEntries;
880 const sessionsByProvider = yield* Effect.forEach(currentAdapters, ([instanceId, adapter]) =>880 const sessionsByProvider = yield* Effect.forEach(currentAdapters, ([instanceId, adapter]) =>
...@@ -895,13 +895,22 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -895,13 +895,22 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
895 (threadId) =>895 (threadId) =>
896 directory896 directory
897 .getBinding(threadId)897 .getBinding(threadId)
898 .pipe(Effect.orElseSucceed(() => Option.none<ProviderRuntimeBinding>())),898 .pipe(
899 Effect.orElseSucceed(() =>
900 Option.none<ProviderSessionDirectory.ProviderRuntimeBinding>(),
901 ),
902 ),
899 { concurrency: "unbounded" },903 { concurrency: "unbounded" },
900 ),904 ),
901 ),905 ),
902 Effect.orElseSucceed(() => [] as Array<Option.Option<ProviderRuntimeBinding>>),906 Effect.orElseSucceed(
907 () => [] as Array<Option.Option<ProviderSessionDirectory.ProviderRuntimeBinding>>,
908 ),
903 );909 );
904 const bindingsByThreadId = new Map<ThreadId, ProviderRuntimeBinding>();910 const bindingsByThreadId = new Map<
911 ThreadId,
912 ProviderSessionDirectory.ProviderRuntimeBinding
913 >();
905 for (const bindingOption of persistedBindings) {914 for (const bindingOption of persistedBindings) {
906 const binding = Option.getOrUndefined(bindingOption);915 const binding = Option.getOrUndefined(bindingOption);
907 if (binding) {916 if (binding) {
...@@ -952,13 +961,13 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -952,13 +961,13 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
952 },961 },
953 );962 );
954963
955 const getCapabilities: ProviderServiceShape["getCapabilities"] = (instanceId) =>964 const getCapabilities: ProviderServiceMethod<"getCapabilities"> = (instanceId) =>
956 registry.getByInstance(instanceId).pipe(Effect.map((adapter) => adapter.capabilities));965 registry.getByInstance(instanceId).pipe(Effect.map((adapter) => adapter.capabilities));
957966
958 const getInstanceInfo: ProviderServiceShape["getInstanceInfo"] = (instanceId) =>967 const getInstanceInfo: ProviderServiceMethod<"getInstanceInfo"> = (instanceId) =>
959 registry.getInstanceInfo(instanceId);968 registry.getInstanceInfo(instanceId);
960969
961 const rollbackConversation: ProviderServiceShape["rollbackConversation"] = Effect.fn(970 const rollbackConversation: ProviderServiceMethod<"rollbackConversation"> = Effect.fn(
962 "rollbackConversation",971 "rollbackConversation",
963 )(function* (rawInput) {972 )(function* (rawInput) {
964 const input = yield* decodeInputOrValidationError({973 const input = yield* decodeInputOrValidationError({
...@@ -1071,14 +1080,17 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -1071,14 +1080,17 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
1071 // Each access creates a fresh PubSub subscription so that multiple1080 // Each access creates a fresh PubSub subscription so that multiple
1072 // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each1081 // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each
1073 // independently receive all runtime events.1082 // independently receive all runtime events.
1074 get streamEvents(): ProviderServiceShape["streamEvents"] {1083 get streamEvents(): ProviderServiceMethod<"streamEvents"> {
1075 return Stream.fromPubSub(runtimeEventPubSub);1084 return Stream.fromPubSub(runtimeEventPubSub);
1076 },1085 },
1077 } satisfies ProviderServiceShape;1086 } satisfies ProviderService.ProviderService["Service"];
1078});1087});
10791088
1080export const ProviderServiceLive = Layer.effect(ProviderService, makeProviderService());1089export const ProviderServiceLive = Layer.effect(
1090 ProviderService.ProviderService,
1091 makeProviderService(),
1092);
10811093
1082export function makeProviderServiceLive(options?: ProviderServiceLiveOptions) {1094export function makeProviderServiceLive(options?: ProviderServiceLiveOptions) {
1083 return Layer.effect(ProviderService, makeProviderService(options));1095 return Layer.effect(ProviderService.ProviderService, makeProviderService(options));
1084}1096}
apps/server/src/server.test.ts+1-1
...@@ -88,7 +88,7 @@ import * as ServerSettings from "./serverSettings.ts";...@@ -88,7 +88,7 @@ import * as ServerSettings from "./serverSettings.ts";
88import * as TerminalManager from "./terminal/Services/Manager.ts";88import * as TerminalManager from "./terminal/Services/Manager.ts";
89import * as PreviewManager from "./preview/Manager.ts";89import * as PreviewManager from "./preview/Manager.ts";
90import * as PortScanner from "./preview/PortScanner.ts";90import * as PortScanner from "./preview/PortScanner.ts";
91import * as BrowserTraceCollector from "./observability/Services/BrowserTraceCollector.ts";91import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
92import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";92import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";
93import * as ProjectSetupScriptRunner from "./project/Services/ProjectSetupScriptRunner.ts";93import * as ProjectSetupScriptRunner from "./project/Services/ProjectSetupScriptRunner.ts";
94import * as RepositoryIdentityResolver from "./project/Services/RepositoryIdentityResolver.ts";94import * as RepositoryIdentityResolver from "./project/Services/RepositoryIdentityResolver.ts";
apps/server/src/server.ts+2-2
...@@ -17,7 +17,7 @@ import { websocketRpcRouteLayer } from "./ws.ts";...@@ -17,7 +17,7 @@ import { websocketRpcRouteLayer } from "./ws.ts";
17import * as ExternalLauncher from "./process/externalLauncher.ts";17import * as ExternalLauncher from "./process/externalLauncher.ts";
18import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";18import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
19import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";19import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
20import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts";20import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
21import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts";21import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts";
22import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts";22import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts";
23import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts";23import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts";
...@@ -332,7 +332,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(...@@ -332,7 +332,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(
332 Layer.provideMerge(ProcessDiagnostics.layer),332 Layer.provideMerge(ProcessDiagnostics.layer),
333 Layer.provideMerge(ProcessResourceMonitor.layer),333 Layer.provideMerge(ProcessResourceMonitor.layer),
334 Layer.provideMerge(TraceDiagnostics.layer),334 Layer.provideMerge(TraceDiagnostics.layer),
335 Layer.provideMerge(AnalyticsServiceLayerLive),335 Layer.provideMerge(AnalyticsService.layer),
336 Layer.provideMerge(ExternalLauncher.layer),336 Layer.provideMerge(ExternalLauncher.layer),
337 Layer.provideMerge(ServerLifecycleEvents.layer),337 Layer.provideMerge(ServerLifecycleEvents.layer),
338 Layer.provide(NetService.layer),338 Layer.provide(NetService.layer),
apps/server/src/serverRuntimeStartup.test.ts+1-1
...@@ -13,7 +13,7 @@ import * as Stream from "effect/Stream";...@@ -13,7 +13,7 @@ import * as Stream from "effect/Stream";
13import * as ServerConfig from "./config.ts";13import * as ServerConfig from "./config.ts";
14import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";14import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
15import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";15import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
16import * as AnalyticsService from "./telemetry/Services/AnalyticsService.ts";16import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
17import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";17import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
1818
19it("uses the canonical Codex default for auto-bootstrapped model selection", () => {19it("uses the canonical Codex default for auto-bootstrapped model selection", () => {
apps/server/src/serverRuntimeStartup.ts+1-1
...@@ -31,7 +31,7 @@ import * as OrchestrationReactor from "./orchestration/Services/OrchestrationRea...@@ -31,7 +31,7 @@ import * as OrchestrationReactor from "./orchestration/Services/OrchestrationRea
31import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";31import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
32import * as ServerSettings from "./serverSettings.ts";32import * as ServerSettings from "./serverSettings.ts";
33import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts";33import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts";
34import * as AnalyticsService from "./telemetry/Services/AnalyticsService.ts";34import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
35import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";35import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
36import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts";36import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts";
37import {37import {
apps/server/src/telemetry/AnalyticsService.test.ts created+120
...@@ -0,0 +1,120 @@
1import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import * as ConfigProvider from "effect/ConfigProvider";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as HttpServer from "effect/unstable/http/HttpServer";
8import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
9import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
10
11import * as ServerConfig from "../config.ts";
12import { getTelemetryIdentifier } from "./Identify.ts";
13import * as AnalyticsService from "./AnalyticsService.ts";
14
15interface RecordedBatchRequest {
16 readonly path: string;
17 readonly body: {
18 readonly batch?: ReadonlyArray<{
19 readonly event?: string;
20 readonly properties?: {
21 readonly index?: number;
22 readonly clientType?: string;
23 };
24 }>;
25 } | null;
26}
27
28interface RecordedBatchBody {
29 readonly batch: ReadonlyArray<{
30 readonly event?: string;
31 readonly properties?: {
32 readonly index?: number;
33 readonly clientType?: string;
34 };
35 }>;
36}
37
38it.layer(NodeServices.layer)("AnalyticsService test", (it) => {
39 it.effect("flush drains all buffered events across multiple batches", () =>
40 Effect.gen(function* () {
41 const capturedRequests: Array<RecordedBatchRequest> = [];
42 const serverConfigLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
43 prefix: "t3-telemetry-base-",
44 });
45
46 const telemetryLayer = AnalyticsService.layer.pipe(Layer.provideMerge(serverConfigLayer));
47 const configLayer = ConfigProvider.layer(
48 ConfigProvider.fromUnknown({
49 T3CODE_TELEMETRY_ENABLED: true,
50 T3CODE_POSTHOG_KEY: "phc_test_key",
51 T3CODE_POSTHOG_HOST: "",
52 T3CODE_TELEMETRY_FLUSH_BATCH_SIZE: 20,
53 }),
54 );
55 const batchServerLayer = HttpServer.serve(
56 Effect.gen(function* () {
57 const request = yield* HttpServerRequest.HttpServerRequest;
58 if (request.method !== "POST") {
59 return HttpServerResponse.empty({ status: 404 });
60 }
61
62 const payload = yield* request.json.pipe(
63 Effect.map((body) => body as RecordedBatchRequest["body"]),
64 Effect.orElseSucceed(() => null),
65 );
66
67 capturedRequests.push({ path: request.url, body: payload });
68
69 return HttpServerResponse.jsonUnsafe({});
70 }),
71 );
72 const runtimeLayer = telemetryLayer.pipe(
73 Layer.provide(configLayer),
74 Layer.provideMerge(NodeHttpServer.layerTest),
75 );
76
77 yield* Effect.gen(function* () {
78 yield* Layer.launch(batchServerLayer).pipe(Effect.forkScoped);
79 const telemetryIdentifier = yield* getTelemetryIdentifier;
80 assert.equal(telemetryIdentifier !== null, true);
81 const analytics = yield* AnalyticsService.AnalyticsService;
82
83 for (let index = 0; index < 45; index += 1) {
84 yield* analytics.record("test.flush.drain", { index });
85 }
86
87 yield* analytics.flush;
88 }).pipe(Effect.provide(runtimeLayer));
89
90 const batchRequests = capturedRequests.filter(
91 (request): request is RecordedBatchRequest & { readonly body: RecordedBatchBody } =>
92 Array.isArray(request.body?.batch),
93 );
94 assert.equal(batchRequests.length, 3);
95 assert.equal(
96 batchRequests.every((request) => request.path === "/batch/" || request.path === "/batch"),
97 true,
98 );
99 const deliveredIndexes = batchRequests.flatMap((request) =>
100 request.body.batch
101 .filter((event) => event.event === "test.flush.drain")
102 .map((event) => event.properties?.index)
103 .filter((index): index is number => typeof index === "number"),
104 );
105
106 const sorted = deliveredIndexes.toSorted((a, b) => a - b);
107 assert.equal(sorted.length, 45);
108 assert.deepEqual(
109 sorted,
110 Array.from({ length: 45 }, (_, index) => index),
111 );
112 assert.equal(
113 batchRequests.every((request) =>
114 request.body.batch.every((event) => event.properties?.clientType === "cli-web-client"),
115 ),
116 true,
117 );
118 }),
119 );
120});
apps/server/src/telemetry/AnalyticsService.ts created+186
...@@ -0,0 +1,186 @@
1/**
2 * Anonymous PostHog telemetry service.
3 *
4 * Persists an installation-scoped anonymous identifier, buffers events in
5 * memory, and flushes batches over Effect's HTTP client.
6 *
7 * @module AnalyticsService
8 */
9import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
10import * as Config from "effect/Config";
11import * as Context from "effect/Context";
12import * as DateTime from "effect/DateTime";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Ref from "effect/Ref";
17import * as HttpClient from "effect/unstable/http/HttpClient";
18import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
19import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
20
21import packageJson from "../../package.json" with { type: "json" };
22import * as ServerConfig from "../config.ts";
23import { getTelemetryIdentifier } from "./Identify.ts";
24
25interface BufferedAnalyticsEvent {
26 readonly event: string;
27 readonly properties?: Readonly<Record<string, unknown>>;
28 readonly capturedAt: string;
29}
30
31const TelemetryEnvConfig = Config.all({
32 posthogKey: Config.string("T3CODE_POSTHOG_KEY").pipe(
33 Config.withDefault("phc_XOWci4oZP4VvLiEyrFqkFjP4CZn55mjYYBMREK5Wd6m"),
34 ),
35 posthogHost: Config.string("T3CODE_POSTHOG_HOST").pipe(
36 Config.withDefault("https://us.i.posthog.com"),
37 ),
38 enabled: Config.boolean("T3CODE_TELEMETRY_ENABLED").pipe(Config.withDefault(true)),
39 flushBatchSize: Config.number("T3CODE_TELEMETRY_FLUSH_BATCH_SIZE").pipe(Config.withDefault(20)),
40 maxBufferedEvents: Config.number("T3CODE_TELEMETRY_MAX_BUFFERED_EVENTS").pipe(
41 Config.withDefault(1_000),
42 ),
43 wslDistroName: Config.string("WSL_DISTRO_NAME").pipe(Config.option),
44});
45
46export class AnalyticsService extends Context.Service<
47 AnalyticsService,
48 {
49 /** Record an anonymous event for best-effort buffered delivery. */
50 readonly record: (
51 event: string,
52 properties?: Readonly<Record<string, unknown>>,
53 ) => Effect.Effect<void>;
54
55 /** Flush all currently queued telemetry events. */
56 readonly flush: Effect.Effect<void>;
57 }
58>()("t3/telemetry/AnalyticsService") {
59 /** No-op layer for callers that intentionally disable telemetry. */
60 static readonly layerTest = Layer.succeed(
61 AnalyticsService,
62 AnalyticsService.of({
63 record: () => Effect.void,
64 flush: Effect.void,
65 }),
66 );
67}
68
69export const make = Effect.gen(function* () {
70 const telemetryConfig = yield* TelemetryEnvConfig;
71 const httpClient = yield* HttpClient.HttpClient;
72 const serverConfig = yield* ServerConfig.ServerConfig;
73 const identifier = yield* getTelemetryIdentifier;
74 const bufferRef = yield* Ref.make<ReadonlyArray<BufferedAnalyticsEvent>>([]);
75 const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client";
76 const hostPlatform = yield* HostProcessPlatform;
77 const hostArchitecture = yield* HostProcessArchitecture;
78
79 const enqueueBufferedEvent = (event: string, properties?: Readonly<Record<string, unknown>>) =>
80 Effect.flatMap(DateTime.now, (now) =>
81 Ref.modify(bufferRef, (current) => {
82 const appended = [
83 ...current,
84 {
85 event,
86 ...(properties ? { properties } : {}),
87 capturedAt: DateTime.formatIso(now),
88 } satisfies BufferedAnalyticsEvent,
89 ];
90
91 const next =
92 appended.length > telemetryConfig.maxBufferedEvents
93 ? appended.slice(appended.length - telemetryConfig.maxBufferedEvents)
94 : appended;
95
96 return [
97 {
98 size: next.length,
99 dropped: next.length !== appended.length,
100 } as const,
101 next,
102 ] as const;
103 }),
104 );
105
106 const sendBatch = Effect.fn("AnalyticsService.sendBatch")(function* (
107 events: ReadonlyArray<BufferedAnalyticsEvent>,
108 ) {
109 if (!telemetryConfig.enabled || !identifier) return;
110
111 const payload = {
112 api_key: telemetryConfig.posthogKey,
113 batch: events.map((event) => ({
114 event: event.event,
115 distinct_id: identifier,
116 properties: {
117 ...event.properties,
118 $process_person_profile: false,
119 platform: hostPlatform,
120 wsl: Option.getOrUndefined(telemetryConfig.wslDistroName),
121 arch: hostArchitecture,
122 t3CodeVersion: packageJson.version,
123 clientType,
124 },
125 timestamp: event.capturedAt,
126 })),
127 };
128
129 yield* HttpClientRequest.post(`${telemetryConfig.posthogHost}/batch/`).pipe(
130 HttpClientRequest.bodyJson(payload),
131 Effect.flatMap(httpClient.execute),
132 Effect.flatMap(HttpClientResponse.filterStatusOk),
133 );
134 });
135
136 const flush: AnalyticsService["Service"]["flush"] = Effect.gen(function* () {
137 while (true) {
138 const batch = yield* Ref.modify(bufferRef, (current) => {
139 if (current.length === 0) {
140 return [[] as ReadonlyArray<BufferedAnalyticsEvent>, current] as const;
141 }
142 const nextBatch = current.slice(0, telemetryConfig.flushBatchSize);
143 const remaining = current.slice(nextBatch.length);
144 return [nextBatch, remaining] as const;
145 });
146
147 if (batch.length === 0) {
148 return;
149 }
150
151 yield* sendBatch(batch).pipe(
152 Effect.catch((error) =>
153 Ref.update(bufferRef, (current) => [...batch, ...current]).pipe(
154 Effect.flatMap(() => Effect.fail(error)),
155 ),
156 ),
157 );
158 }
159 }).pipe(Effect.catch((cause) => Effect.logError("Failed to flush telemetry", { cause })));
160
161 const record: AnalyticsService["Service"]["record"] = Effect.fn("AnalyticsService.record")(
162 function* (event, properties) {
163 if (!telemetryConfig.enabled || !identifier) return;
164
165 const enqueueResult = yield* enqueueBufferedEvent(event, properties);
166 if (enqueueResult.dropped) {
167 yield* Effect.logDebug("analytics buffer full; dropping oldest event", {
168 size: enqueueResult.size,
169 event,
170 });
171 }
172 },
173 );
174
175 yield* Effect.forever(Effect.sleep(1000).pipe(Effect.flatMap(() => flush)), {
176 disableYield: true,
177 }).pipe(Effect.forkScoped);
178
179 yield* Effect.addFinalizer(() => flush);
180
181 return AnalyticsService.of({ record, flush });
182});
183
184export const layer = Layer.effect(AnalyticsService, make);
185
186export const layerTest = AnalyticsService.layerTest;
apps/server/src/telemetry/Identify.ts+12-6
...@@ -6,7 +6,7 @@ import * as FileSystem from "effect/FileSystem";...@@ -6,7 +6,7 @@ import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";6import * as Path from "effect/Path";
7import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
88
9import { ServerConfig } from "../config.ts";9import * as ServerConfig from "../config.ts";
1010
11const CodexAuthJsonSchema = Schema.Struct({11const CodexAuthJsonSchema = Schema.Struct({
12 tokens: Schema.Struct({12 tokens: Schema.Struct({
...@@ -19,9 +19,14 @@ const ClaudeJsonSchema = Schema.Struct({...@@ -19,9 +19,14 @@ const ClaudeJsonSchema = Schema.Struct({
19});19});
2020
21class IdentifyUserError extends Schema.TaggedErrorClass<IdentifyUserError>()("IdentifyUserError", {21class IdentifyUserError extends Schema.TaggedErrorClass<IdentifyUserError>()("IdentifyUserError", {
22 message: Schema.String,22 operation: Schema.Literal("hash_identifier"),
23 cause: Schema.optional(Schema.Defect()),23 algorithm: Schema.Literal("SHA-256"),
24}) {}24 cause: Schema.Defect(),
25}) {
26 override get message(): string {
27 return `Failed to hash telemetry identifier with ${this.algorithm}.`;
28 }
29}
2530
26const hash = (value: string) =>31const hash = (value: string) =>
27 Crypto.Crypto.pipe(32 Crypto.Crypto.pipe(
...@@ -30,7 +35,8 @@ const hash = (value: string) =>...@@ -30,7 +35,8 @@ const hash = (value: string) =>
30 Effect.mapError(35 Effect.mapError(
31 (cause) =>36 (cause) =>
32 new IdentifyUserError({37 new IdentifyUserError({
33 message: "Failed to hash identifier",38 operation: "hash_identifier",
39 algorithm: "SHA-256",
34 cause,40 cause,
35 }),41 }),
36 ),42 ),
...@@ -64,7 +70,7 @@ const getClaudeUserId = Effect.gen(function* () {...@@ -64,7 +70,7 @@ const getClaudeUserId = Effect.gen(function* () {
6470
65const upsertAnonymousId = Effect.gen(function* () {71const upsertAnonymousId = Effect.gen(function* () {
66 const fileSystem = yield* FileSystem.FileSystem;72 const fileSystem = yield* FileSystem.FileSystem;
67 const { anonymousIdPath } = yield* ServerConfig;73 const { anonymousIdPath } = yield* ServerConfig.ServerConfig;
6874
69 const anonymousId = yield* fileSystem.readFileString(anonymousIdPath).pipe(75 const anonymousId = yield* fileSystem.readFileString(anonymousIdPath).pipe(
70 Effect.catch(() =>76 Effect.catch(() =>
apps/server/src/telemetry/Layers/AnalyticsService.test.ts deleted-121
...@@ -1,121 +0,0 @@
1import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import * as ConfigProvider from "effect/ConfigProvider";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as HttpServer from "effect/unstable/http/HttpServer";
8import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
9import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
10
11import { ServerConfig } from "../../config.ts";
12import { getTelemetryIdentifier } from "../Identify.ts";
13import { AnalyticsService } from "../Services/AnalyticsService.ts";
14import { AnalyticsServiceLayerLive } from "./AnalyticsService.ts";
15
16interface RecordedBatchRequest {
17 readonly path: string;
18 readonly body: {
19 readonly batch?: ReadonlyArray<{
20 readonly event?: string;
21 readonly properties?: {
22 readonly index?: number;
23 readonly clientType?: string;
24 };
25 }>;
26 } | null;
27}
28
29interface RecordedBatchBody {
30 readonly batch: ReadonlyArray<{
31 readonly event?: string;
32 readonly properties?: {
33 readonly index?: number;
34 readonly clientType?: string;
35 };
36 }>;
37}
38
39it.layer(NodeServices.layer)("AnalyticsService test", (it) => {
40 it.effect("flush drains all buffered events across multiple batches", () =>
41 Effect.gen(function* () {
42 const capturedRequests: Array<RecordedBatchRequest> = [];
43 const serverConfigLayer = ServerConfig.layerTest(process.cwd(), {
44 prefix: "t3-telemetry-base-",
45 });
46
47 const telemetryLayer = AnalyticsServiceLayerLive.pipe(Layer.provideMerge(serverConfigLayer));
48 const configLayer = ConfigProvider.layer(
49 ConfigProvider.fromUnknown({
50 T3CODE_TELEMETRY_ENABLED: true,
51 T3CODE_POSTHOG_KEY: "phc_test_key",
52 T3CODE_POSTHOG_HOST: "",
53 T3CODE_TELEMETRY_FLUSH_BATCH_SIZE: 20,
54 }),
55 );
56 const batchServerLayer = HttpServer.serve(
57 Effect.gen(function* () {
58 const request = yield* HttpServerRequest.HttpServerRequest;
59 if (request.method !== "POST") {
60 return HttpServerResponse.empty({ status: 404 });
61 }
62
63 const payload = yield* request.json.pipe(
64 Effect.map((body) => body as RecordedBatchRequest["body"]),
65 Effect.orElseSucceed(() => null),
66 );
67
68 capturedRequests.push({ path: request.url, body: payload });
69
70 return HttpServerResponse.jsonUnsafe({});
71 }),
72 );
73 const runtimeLayer = telemetryLayer.pipe(
74 Layer.provide(configLayer),
75 Layer.provideMerge(NodeHttpServer.layerTest),
76 );
77
78 yield* Effect.gen(function* () {
79 yield* Layer.launch(batchServerLayer).pipe(Effect.forkScoped);
80 const telemetryIdentifier = yield* getTelemetryIdentifier;
81 assert.equal(telemetryIdentifier !== null, true);
82 const analytics = yield* AnalyticsService;
83
84 for (let index = 0; index < 45; index += 1) {
85 yield* analytics.record("test.flush.drain", { index });
86 }
87
88 yield* analytics.flush;
89 }).pipe(Effect.provide(runtimeLayer));
90
91 const batchRequests = capturedRequests.filter(
92 (request): request is RecordedBatchRequest & { readonly body: RecordedBatchBody } =>
93 Array.isArray(request.body?.batch),
94 );
95 assert.equal(batchRequests.length, 3);
96 assert.equal(
97 batchRequests.every((request) => request.path === "/batch/" || request.path === "/batch"),
98 true,
99 );
100 const deliveredIndexes = batchRequests.flatMap((request) =>
101 request.body.batch
102 .filter((event) => event.event === "test.flush.drain")
103 .map((event) => event.properties?.index)
104 .filter((index): index is number => typeof index === "number"),
105 );
106
107 const sorted = deliveredIndexes.toSorted((a, b) => a - b);
108 assert.equal(sorted.length, 45);
109 assert.deepEqual(
110 sorted,
111 Array.from({ length: 45 }, (_, index) => index),
112 );
113 assert.equal(
114 batchRequests.every((request) =>
115 request.body.batch.every((event) => event.properties?.clientType === "cli-web-client"),
116 ),
117 true,
118 );
119 }),
120 );
121});
apps/server/src/telemetry/Layers/AnalyticsService.ts deleted-163
...@@ -1,163 +0,0 @@
1/**
2 * AnalyticsServiceLive - Anonymous PostHog telemetry layer.
3 *
4 * Persists a random installation-scoped anonymous id to state dir, buffers
5 * events in memory, and flushes batches to PostHog over Effect HttpClient.
6 *
7 * @module AnalyticsServiceLive
8 */
9
10import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
11import * as Config from "effect/Config";
12import * as DateTime from "effect/DateTime";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Ref from "effect/Ref";
17import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
18
19import { ServerConfig } from "../../config.ts";
20import { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts";
21import { getTelemetryIdentifier } from "../Identify.ts";
22import packageJson from "../../../package.json" with { type: "json" };
23
24interface BufferedAnalyticsEvent {
25 readonly event: string;
26 readonly properties?: Readonly<Record<string, unknown>>;
27 readonly capturedAt: string;
28}
29
30const TelemetryEnvConfig = Config.all({
31 posthogKey: Config.string("T3CODE_POSTHOG_KEY").pipe(
32 Config.withDefault("phc_XOWci4oZP4VvLiEyrFqkFjP4CZn55mjYYBMREK5Wd6m"),
33 ),
34 posthogHost: Config.string("T3CODE_POSTHOG_HOST").pipe(
35 Config.withDefault("https://us.i.posthog.com"),
36 ),
37 enabled: Config.boolean("T3CODE_TELEMETRY_ENABLED").pipe(Config.withDefault(true)),
38 flushBatchSize: Config.number("T3CODE_TELEMETRY_FLUSH_BATCH_SIZE").pipe(Config.withDefault(20)),
39 maxBufferedEvents: Config.number("T3CODE_TELEMETRY_MAX_BUFFERED_EVENTS").pipe(
40 Config.withDefault(1_000),
41 ),
42 wslDistroName: Config.string("WSL_DISTRO_NAME").pipe(Config.option),
43});
44
45const makeAnalyticsService = Effect.gen(function* () {
46 const telemetryConfig = yield* TelemetryEnvConfig;
47 const httpClient = yield* HttpClient.HttpClient;
48 const serverConfig = yield* ServerConfig;
49 const identifier = yield* getTelemetryIdentifier;
50 const bufferRef = yield* Ref.make<ReadonlyArray<BufferedAnalyticsEvent>>([]);
51 const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client";
52 const hostPlatform = yield* HostProcessPlatform;
53 const hostArchitecture = yield* HostProcessArchitecture;
54
55 const enqueueBufferedEvent = (event: string, properties?: Readonly<Record<string, unknown>>) =>
56 Effect.flatMap(DateTime.now, (now) =>
57 Ref.modify(bufferRef, (current) => {
58 const appended = [
59 ...current,
60 {
61 event,
62 ...(properties ? { properties } : {}),
63 capturedAt: DateTime.formatIso(now),
64 } satisfies BufferedAnalyticsEvent,
65 ];
66
67 const next =
68 appended.length > telemetryConfig.maxBufferedEvents
69 ? appended.slice(appended.length - telemetryConfig.maxBufferedEvents)
70 : appended;
71
72 return [
73 {
74 size: next.length,
75 dropped: next.length !== appended.length,
76 } as const,
77 next,
78 ] as const;
79 }),
80 );
81
82 const sendBatch = Effect.fn("sendBatch")(function* (
83 events: ReadonlyArray<BufferedAnalyticsEvent>,
84 ) {
85 if (!telemetryConfig.enabled || !identifier) return;
86
87 const payload = {
88 api_key: telemetryConfig.posthogKey,
89 batch: events.map((event) => ({
90 event: event.event,
91 distinct_id: identifier,
92 properties: {
93 ...event.properties,
94 $process_person_profile: false,
95 platform: hostPlatform,
96 wsl: Option.getOrUndefined(telemetryConfig.wslDistroName),
97 arch: hostArchitecture,
98 t3CodeVersion: packageJson.version,
99 clientType,
100 },
101 timestamp: event.capturedAt,
102 })),
103 };
104
105 yield* HttpClientRequest.post(`${telemetryConfig.posthogHost}/batch/`).pipe(
106 HttpClientRequest.bodyJson(payload),
107 Effect.flatMap(httpClient.execute),
108 Effect.flatMap(HttpClientResponse.filterStatusOk),
109 );
110 });
111
112 const flush: AnalyticsServiceShape["flush"] = Effect.gen(function* () {
113 while (true) {
114 const batch = yield* Ref.modify(bufferRef, (current) => {
115 if (current.length === 0) {
116 return [[] as ReadonlyArray<BufferedAnalyticsEvent>, current] as const;
117 }
118 const nextBatch = current.slice(0, telemetryConfig.flushBatchSize);
119 const remaining = current.slice(nextBatch.length);
120 return [nextBatch, remaining] as const;
121 });
122
123 if (batch.length === 0) {
124 return;
125 }
126
127 yield* sendBatch(batch).pipe(
128 Effect.catch((error) =>
129 Ref.update(bufferRef, (current) => [...batch, ...current]).pipe(
130 Effect.flatMap(() => Effect.fail(error)),
131 ),
132 ),
133 );
134 }
135 }).pipe(Effect.catch((cause) => Effect.logError("Failed to flush telemetry", { cause })));
136
137 const record: AnalyticsServiceShape["record"] = Effect.fn("record")(
138 function* (event, properties) {
139 if (!telemetryConfig.enabled || !identifier) return;
140
141 const enqueueResult = yield* enqueueBufferedEvent(event, properties);
142 if (enqueueResult.dropped) {
143 yield* Effect.logDebug("analytics buffer full; dropping oldest event", {
144 size: enqueueResult.size,
145 event,
146 });
147 }
148 },
149 );
150
151 yield* Effect.forever(Effect.sleep(1000).pipe(Effect.flatMap(() => flush)), {
152 disableYield: true,
153 }).pipe(Effect.forkScoped);
154
155 yield* Effect.addFinalizer(() => flush);
156
157 return {
158 record,
159 flush,
160 } satisfies AnalyticsServiceShape;
161});
162
163export const AnalyticsServiceLayerLive = Layer.effect(AnalyticsService, makeAnalyticsService);
apps/server/src/telemetry/Services/AnalyticsService.ts+2-35
...@@ -1,35 +1,2 @@...@@ -1,35 +1,2 @@
1/**1// Compatibility shim for the intentionally excluded orchestration harness.
2 * AnalyticsService - Anonymous telemetry capture contract.2export { AnalyticsService } from "../AnalyticsService.ts";
3 *
4 * Provides a best-effort event API for runtime telemetry and a strict
5 * `captureImmediate` method for call sites that need explicit error handling.
6 *
7 * @module AnalyticsService
8 */
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as Context from "effect/Context";
12
13export interface AnalyticsServiceShape {
14 /**
15 * Capture an event immediately; returns typed failure when capture fails.
16 */
17 readonly record: (
18 event: string,
19 properties?: Readonly<Record<string, unknown>>,
20 ) => Effect.Effect<void, never>;
21
22 /**
23 * Flush queued telemetry.
24 */
25 readonly flush: Effect.Effect<void, never>;
26}
27
28export class AnalyticsService extends Context.Service<AnalyticsService, AnalyticsServiceShape>()(
29 "t3/telemetry/Services/AnalyticsService",
30) {
31 static readonly layerTest = Layer.succeed(AnalyticsService, {
32 record: () => Effect.void,
33 flush: Effect.void,
34 });
35}