authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-29 14:42:01+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-29 14:42:01+02:00
log49c0d96edf280f1fe6750a83161a3a2cff0f25ef
tree7cdd1dbdb6fda6a1459dc5a0367f709d86abf54b
parent3137c2b162109640343a6ff88120609e061f3782
signature Signed by PGP key B5690EEEBB952194

Reduce idle work and disk churn with native resource diagnostics (#2679)

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

122 files changed, 18478 insertions(+), 2154 deletions(-)

.github/workflows/ci.yml+14
......@@ -22,6 +22,11 @@ jobs:
2222 cache: true
2323 run-install: true
2424
25 - name: Setup Rust
26 uses: dtolnay/rust-toolchain@stable
27 with:
28 components: rustfmt
29
2530 - name: Ensure Electron runtime is installed
2631 run: vp run --filter @t3tools/desktop ensure:electron
2732
......@@ -31,6 +36,9 @@ jobs:
3136 - name: Typecheck
3237 run: vpr typecheck
3338
39 - name: Check resource monitor formatting
40 run: cargo fmt --manifest-path native/resource-monitor/Cargo.toml -- --check
41
3442 - name: Build desktop pipeline
3543 run: vp run build:desktop
3644
......@@ -55,12 +63,18 @@ jobs:
5563 cache: true
5664 run-install: true
5765
66 - name: Setup Rust
67 uses: dtolnay/rust-toolchain@stable
68
5869 - name: Ensure Electron runtime is installed
5970 run: vp run --filter @t3tools/desktop ensure:electron
6071
6172 - name: Test
6273 run: vp run test
6374
75 - name: Test resource monitor
76 run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml
77
6478 mobile_native_static_analysis:
6579 name: Mobile Native Static Analysis
6680 runs-on: blacksmith-12vcpu-macos-26
.github/workflows/release.yml+51
......@@ -326,21 +326,29 @@ jobs:
326326 platform: mac
327327 target: dmg
328328 arch: arm64
329 rust_target: aarch64-apple-darwin
330 resource_key: darwin-arm64
329331 - label: macOS x64
330332 runner: blacksmith-12vcpu-macos-26
331333 platform: mac
332334 target: dmg
333335 arch: x64
336 rust_target: x86_64-apple-darwin
337 resource_key: darwin-x64
334338 - label: Linux x64
335339 runner: blacksmith-32vcpu-ubuntu-2404
336340 platform: linux
337341 target: AppImage
338342 arch: x64
343 rust_target: x86_64-unknown-linux-gnu
344 resource_key: linux-x64
339345 - label: Windows x64
340346 runner: blacksmith-32vcpu-windows-2025
341347 platform: win
342348 target: nsis
343349 arch: x64
350 rust_target: x86_64-pc-windows-msvc
351 resource_key: win32-x64
344352 # - label: Windows arm64
345353 # runner: windows-11-arm
346354 # platform: win
......@@ -360,6 +368,11 @@ jobs:
360368 cache: true
361369 run-install: true
362370
371 - name: Setup Rust
372 uses: dtolnay/rust-toolchain@stable
373 with:
374 targets: ${{ matrix.rust_target }}
375
363376 - name: Download relay client tracing config
364377 uses: actions/download-artifact@v8
365378 with:
......@@ -598,6 +611,19 @@ jobs:
598611 # done
599612 # fi
600613
614 - name: Collect resource monitor
615 shell: bash
616 run: |
617 set -euo pipefail
618 binary_name="t3-resource-monitor"
619 if [[ "${{ matrix.platform }}" == "win" ]]; then
620 binary_name="${binary_name}.exe"
621 fi
622 source_path="native/resource-monitor/target/${{ matrix.rust_target }}/release/${binary_name}"
623 target_dir="resource-monitor-publish/${{ matrix.resource_key }}"
624 mkdir -p "$target_dir"
625 cp "$source_path" "$target_dir/$binary_name"
626
601627 - name: Upload build artifacts
602628 uses: actions/upload-artifact@v7
603629 with:
......@@ -605,6 +631,13 @@ jobs:
605631 path: release-publish/*
606632 if-no-files-found: error
607633
634 - name: Upload resource monitor
635 uses: actions/upload-artifact@v7
636 with:
637 name: resource-monitor-${{ matrix.resource_key }}
638 path: resource-monitor-publish/${{ matrix.resource_key }}/*
639 if-no-files-found: error
640
608641 publish_cli:
609642 name: Publish CLI to npm
610643 needs: [preflight, relay_public_config, build]
......@@ -659,6 +692,24 @@ jobs:
659692 - name: Build CLI package
660693 run: vp run --filter t3 build
661694
695 - name: Download resource monitors
696 uses: actions/download-artifact@v8
697 with:
698 pattern: resource-monitor-*
699 path: ${{ runner.temp }}/resource-monitors
700
701 - name: Bundle resource monitors into CLI package
702 shell: bash
703 run: |
704 set -euo pipefail
705 for artifact_dir in "$RUNNER_TEMP"/resource-monitors/resource-monitor-*; do
706 resource_key="${artifact_dir##*/resource-monitor-}"
707 target_dir="apps/server/dist/resource-monitor/${resource_key}"
708 mkdir -p "$target_dir"
709 cp "$artifact_dir"/t3-resource-monitor* "$target_dir/"
710 chmod +x "$target_dir"/t3-resource-monitor 2>/dev/null || true
711 done
712
662713 - name: Publish CLI package
663714 run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose
664715
.gitignore+1
......@@ -30,6 +30,7 @@ dist-electron/
3030.showcase/
3131apps/mobile/.showcase/
3232artifacts/app-store/screenshots/
33native/**/target/
3334node_modules/
3435.alchemy/
3536*.log
apps/desktop/src/app/DesktopAppIdentity.test.ts+1
......@@ -55,6 +55,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
5555 }),
5656 setAppUserModelId: () => Effect.void,
5757 requestSingleInstanceLock: Effect.succeed(true),
58 getAppMetrics: Effect.succeed([]),
5859 isDefaultProtocolClient: () => Effect.succeed(false),
5960 setAsDefaultProtocolClient: () => Effect.succeed(true),
6061 setDesktopName: () => Effect.void,
apps/desktop/src/app/DesktopLifecycle.test.ts+1
......@@ -30,6 +30,7 @@ describe("DesktopLifecycle", () => {
3030 setAboutPanelOptions: () => Effect.void,
3131 setAppUserModelId: () => Effect.void,
3232 requestSingleInstanceLock: Effect.succeed(true),
33 getAppMetrics: Effect.succeed([]),
3334 isDefaultProtocolClient: () => Effect.succeed(false),
3435 setAsDefaultProtocolClient: () => Effect.succeed(true),
3536 setDesktopName: () => Effect.void,
apps/desktop/src/app/DesktopObservability.test.ts+180-14
......@@ -49,20 +49,41 @@ const environmentInput = (baseDir: string) =>
4949 runningUnderArm64Translation: false,
5050 }) satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;
5151
52const makeEnvironmentLayer = (baseDir: string) =>
52const makeEnvironmentLayer = (baseDir: string, isDevelopment = true) =>
5353 DesktopEnvironment.layer(environmentInput(baseDir)).pipe(
5454 Layer.provide(
5555 Layer.mergeAll(
5656 NodeServices.layer,
5757 DesktopConfig.layerTest({
5858 T3CODE_HOME: baseDir,
59 VITE_DEV_SERVER_URL: "http://127.0.0.1:5733",
59 VITE_DEV_SERVER_URL: isDevelopment ? "http://127.0.0.1:5733" : undefined,
6060 }),
6161 ),
6262 ),
6363 );
6464
6565describe("DesktopObservability", () => {
66 it("advances a retained output offset instead of repeatedly copying a full head chunk", () => {
67 const maxBufferedBytes = 1024 * 1024;
68 const initial = DesktopObservability.appendBoundedOutputChunk(
69 {
70 runId: "test-run",
71 startDetails: "pid=123",
72 chunks: [],
73 byteLength: 0,
74 },
75 "stderr",
76 new Uint8Array(maxBufferedBytes),
77 );
78 const initialBackingBuffer = initial.chunks[0]?.chunk.buffer;
79
80 const next = DesktopObservability.appendBoundedOutputChunk(initial, "stderr", Uint8Array.of(1));
81
82 assert.equal(next.chunks[0]?.chunk.buffer, initialBackingBuffer);
83 assert.equal(next.chunks[0]?.offset, 1);
84 assert.equal(next.byteLength, maxBufferedBytes);
85 });
86
6687 it.effect("persists desktop Effect logs as span events in desktop.trace.ndjson", () =>
6788 Effect.gen(function* () {
6889 const fileSystem = yield* FileSystem.FileSystem;
......@@ -112,26 +133,34 @@ describe("DesktopObservability", () => {
112133 ),
113134 );
114135
115 it.effect("persists backend child output as structured JSON records in development", () =>
136 it.effect("buffers backend child output and persists it only when a failure is reported", () =>
116137 Effect.gen(function* () {
117138 const fileSystem = yield* FileSystem.FileSystem;
118139 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
119140 prefix: "t3-desktop-backend-output-log-test-",
120141 });
121 const environmentLayer = makeEnvironmentLayer(baseDir);
142 const environmentLayer = makeEnvironmentLayer(baseDir, false);
122143 const logPath = yield* Effect.gen(function* () {
123144 const environment = yield* DesktopEnvironment.DesktopEnvironment;
124145 return environment.path.join(environment.logDir, "server-child.log");
125146 }).pipe(Effect.provide(environmentLayer));
147 const tracePath = yield* Effect.gen(function* () {
148 const environment = yield* DesktopEnvironment.DesktopEnvironment;
149 return environment.path.join(environment.logDir, "desktop.trace.ndjson");
150 }).pipe(Effect.provide(environmentLayer));
126151
127152 yield* Effect.gen(function* () {
128153 const factory = yield* DesktopObservability.DesktopBackendOutputLogFactory;
129154 const outputLog = yield* factory.forInstance("primary");
130 yield* outputLog.writeSessionBoundary({
131 phase: "START",
155 yield* outputLog.beginSession({
132156 details: "pid=123 port=3773 cwd=/repo",
133157 });
134158 yield* outputLog.writeOutputChunk("stdout", new TextEncoder().encode("hello server\n"));
159 assert.isFalse(yield* fileSystem.exists(logPath));
160 yield* outputLog.persistFailure({ details: "code=1" });
161 yield* outputLog.beginSession({ details: "pid=456" });
162 yield* outputLog.writeOutputChunk("stderr", new TextEncoder().encode("normal shutdown\n"));
163 yield* outputLog.discardSession;
135164 }).pipe(
136165 Effect.annotateLogs({ runId: "test-run" }),
137166 Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))),
......@@ -139,16 +168,18 @@ describe("DesktopObservability", () => {
139168
140169 const log = yield* fileSystem.readFileString(logPath);
141170 const lines = log.trimEnd().split("\n");
142 const boundary = yield* decodeDesktopBackendChildLogRecord(lines[0] ?? "");
171 const start = yield* decodeDesktopBackendChildLogRecord(lines[0] ?? "");
143172 const output = yield* decodeDesktopBackendChildLogRecord(lines[1] ?? "");
173 const end = yield* decodeDesktopBackendChildLogRecord(lines[2] ?? "");
144174
145 assert.equal(boundary.message, "backend child process session start");
146 assert.equal(boundary.level, "INFO");
147 assert.equal(boundary.annotations.component, "desktop-backend-child");
148 assert.equal(boundary.annotations.runId, "test-run");
149 assert.equal(boundary.annotations.instanceId, "primary");
150 assert.equal(boundary.annotations.phase, "START");
151 assert.equal(boundary.annotations.details, "pid=123 port=3773 cwd=/repo");
175 assert.equal(lines.length, 3);
176 assert.equal(start.message, "backend child process failure output start");
177 assert.equal(start.level, "ERROR");
178 assert.equal(start.annotations.component, "desktop-backend-child");
179 assert.equal(start.annotations.runId, "test-run");
180 assert.equal(start.annotations.instanceId, "primary");
181 assert.equal(start.annotations.phase, "START");
182 assert.equal(start.annotations.details, "pid=123 port=3773 cwd=/repo");
152183
153184 assert.equal(output.message, "backend child process output");
154185 assert.equal(output.level, "INFO");
......@@ -157,6 +188,141 @@ describe("DesktopObservability", () => {
157188 assert.equal(output.annotations.instanceId, "primary");
158189 assert.equal(output.annotations.stream, "stdout");
159190 assert.equal(output.annotations.text, "hello server\n");
191
192 assert.equal(end.message, "backend child process failure output end");
193 assert.equal(end.level, "ERROR");
194 assert.equal(end.annotations.instanceId, "primary");
195 assert.equal(end.annotations.phase, "END");
196 assert.equal(end.annotations.details, "code=1");
197
198 const traceRecords = (yield* fileSystem.readFileString(tracePath))
199 .trim()
200 .split("\n")
201 .filter((line) => line.length > 0)
202 .map((line) => decodeTraceRecordLine(line));
203 assert.isFalse(
204 traceRecords.some(
205 (record) => record.name === "desktop.observability.backendOutput.writeOutputChunk",
206 ),
207 );
208 }).pipe(
209 Effect.scoped,
210 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)),
211 ),
212 );
213
214 it.effect("keeps buffering output after a non-terminal failure snapshot", () =>
215 Effect.gen(function* () {
216 const fileSystem = yield* FileSystem.FileSystem;
217 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
218 prefix: "t3-desktop-backend-output-snapshot-test-",
219 });
220 const environmentLayer = makeEnvironmentLayer(baseDir, false);
221 const logPath = yield* Effect.gen(function* () {
222 const environment = yield* DesktopEnvironment.DesktopEnvironment;
223 return environment.path.join(environment.logDir, "server-child.log");
224 }).pipe(Effect.provide(environmentLayer));
225
226 yield* Effect.gen(function* () {
227 const factory = yield* DesktopObservability.DesktopBackendOutputLogFactory;
228 const outputLog = yield* factory.forInstance("primary");
229 yield* outputLog.beginSession({ details: "pid=123" });
230 yield* outputLog.writeOutputChunk("stdout", new TextEncoder().encode("before timeout\n"));
231 yield* outputLog.persistFailureSnapshot({ details: "readiness timeout" });
232 yield* outputLog.writeOutputChunk("stderr", new TextEncoder().encode("after timeout\n"));
233 yield* outputLog.persistFailure({ details: "code=1" });
234 }).pipe(
235 Effect.annotateLogs({ runId: "test-run" }),
236 Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))),
237 );
238
239 const records = yield* Effect.forEach(
240 (yield* fileSystem.readFileString(logPath)).trimEnd().split("\n"),
241 (line) => decodeDesktopBackendChildLogRecord(line),
242 );
243 assert.equal(
244 records.some((record) => record.annotations.text === "after timeout\n"),
245 true,
246 );
247 assert.equal(records.at(-1)?.annotations.details, "code=1");
248 }).pipe(
249 Effect.scoped,
250 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)),
251 ),
252 );
253
254 it.effect("retains only the last mebibyte of backend child output", () =>
255 Effect.gen(function* () {
256 const fileSystem = yield* FileSystem.FileSystem;
257 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
258 prefix: "t3-desktop-backend-output-bound-test-",
259 });
260 const environmentLayer = makeEnvironmentLayer(baseDir, false);
261 const logPath = yield* Effect.gen(function* () {
262 const environment = yield* DesktopEnvironment.DesktopEnvironment;
263 return environment.path.join(environment.logDir, "server-child.log");
264 }).pipe(Effect.provide(environmentLayer));
265 const maxBufferedBytes = 1024 * 1024;
266 const discardedPrefixBytes = 128;
267 const output = new Uint8Array(maxBufferedBytes + discardedPrefixBytes);
268 output.fill("x".charCodeAt(0));
269 output.fill("y".charCodeAt(0), 0, discardedPrefixBytes);
270
271 yield* Effect.scoped(
272 Effect.gen(function* () {
273 const factory = yield* DesktopObservability.DesktopBackendOutputLogFactory;
274 const outputLog = yield* factory.forInstance("primary");
275 yield* outputLog.beginSession({ details: "pid=123" });
276 yield* outputLog.writeOutputChunk("stderr", output);
277 yield* outputLog.persistFailure({ details: "code=1" });
278 }).pipe(
279 Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))),
280 ),
281 );
282
283 const lines = (yield* fileSystem.readFileString(logPath)).trimEnd().split("\n");
284 const record = yield* decodeDesktopBackendChildLogRecord(lines[1] ?? "");
285 const text = record.annotations.text;
286 assert.equal(typeof text, "string");
287 if (typeof text !== "string") {
288 return;
289 }
290 assert.equal(new TextEncoder().encode(text).byteLength, maxBufferedBytes);
291 assert.isFalse(text.includes("y"));
292 }).pipe(
293 Effect.scoped,
294 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)),
295 ),
296 );
297
298 it.effect("bounds the number of retained backend child output chunks", () =>
299 Effect.gen(function* () {
300 const fileSystem = yield* FileSystem.FileSystem;
301 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
302 prefix: "t3-desktop-backend-output-chunks-test-",
303 });
304 const environmentLayer = makeEnvironmentLayer(baseDir, false);
305 const logPath = yield* Effect.gen(function* () {
306 const environment = yield* DesktopEnvironment.DesktopEnvironment;
307 return environment.path.join(environment.logDir, "server-child.log");
308 }).pipe(Effect.provide(environmentLayer));
309
310 yield* Effect.scoped(
311 Effect.gen(function* () {
312 const factory = yield* DesktopObservability.DesktopBackendOutputLogFactory;
313 const outputLog = yield* factory.forInstance("primary");
314 yield* outputLog.beginSession({ details: "pid=123" });
315 for (let index = 0; index < 300; index += 1) {
316 yield* outputLog.writeOutputChunk("stderr", Uint8Array.of(index % 128));
317 }
318 yield* outputLog.persistFailure({ details: "code=1" });
319 }).pipe(
320 Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))),
321 ),
322 );
323
324 const lines = (yield* fileSystem.readFileString(logPath)).trimEnd().split("\n");
325 assert.equal(lines.length, 258);
160326 }).pipe(
161327 Effect.scoped,
162328 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)),
apps/desktop/src/app/DesktopObservability.ts+164-39
......@@ -24,7 +24,9 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts";
2424const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024;
2525const DESKTOP_LOG_FILE_MAX_FILES = 10;
2626const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child";
27const DESKTOP_TRACE_BATCH_WINDOW_MS = 200;
27const DESKTOP_TRACE_BATCH_WINDOW_MS = 1_000;
28const DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_BYTES = 1024 * 1024;
29const DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_CHUNKS = 256;
2830
2931export interface RotatingLogFileWriter {
3032 readonly writeBytes: (chunk: Uint8Array) => Effect.Effect<void>;
......@@ -32,14 +34,14 @@ export interface RotatingLogFileWriter {
3234}
3335
3436export interface DesktopBackendOutputLogShape {
35 readonly writeSessionBoundary: (input: {
36 readonly phase: "START" | "END";
37 readonly details: string;
38 }) => Effect.Effect<void>;
37 readonly beginSession: (input: { readonly details: string }) => Effect.Effect<void>;
3938 readonly writeOutputChunk: (
4039 streamName: "stdout" | "stderr",
4140 chunk: Uint8Array,
4241 ) => Effect.Effect<void>;
42 readonly persistFailureSnapshot: (input: { readonly details: string }) => Effect.Effect<void>;
43 readonly persistFailure: (input: { readonly details: string }) => Effect.Effect<void>;
44 readonly discardSession: Effect.Effect<void>;
4345}
4446
4547// Factory for per-instance backend output logs. `forInstance(id)` returns
......@@ -127,10 +129,80 @@ const encodeDesktopBackendChildLogRecord = Schema.encodeEffect(
127129);
128130
129131const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = {
130 writeSessionBoundary: () => Effect.void,
132 beginSession: () => Effect.void,
131133 writeOutputChunk: () => Effect.void,
134 persistFailureSnapshot: () => Effect.void,
135 persistFailure: () => Effect.void,
136 discardSession: Effect.void,
132137};
133138
139interface BufferedBackendOutputChunk {
140 readonly streamName: "stdout" | "stderr";
141 readonly chunk: Uint8Array;
142 readonly offset: number;
143}
144
145interface BackendOutputSession {
146 readonly runId: string;
147 readonly startDetails: string;
148 readonly chunks: ReadonlyArray<BufferedBackendOutputChunk>;
149 readonly byteLength: number;
150}
151
152export function appendBoundedOutputChunk(
153 session: BackendOutputSession,
154 streamName: "stdout" | "stderr",
155 chunk: Uint8Array,
156): BackendOutputSession {
157 if (chunk.byteLength === 0) {
158 return session;
159 }
160
161 const retainedChunk =
162 chunk.byteLength > DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_BYTES
163 ? chunk.slice(chunk.byteLength - DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_BYTES)
164 : chunk.slice();
165 const chunks = [...session.chunks, { streamName, chunk: retainedChunk, offset: 0 }];
166 let byteLength = session.byteLength + retainedChunk.byteLength;
167 let overflow = Math.max(0, byteLength - DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_BYTES);
168 let firstRetainedIndex = 0;
169
170 while (overflow > 0) {
171 const first = chunks[firstRetainedIndex];
172 if (!first) break;
173 const retainedByteLength = first.chunk.byteLength - first.offset;
174 if (retainedByteLength <= overflow) {
175 overflow -= retainedByteLength;
176 byteLength -= retainedByteLength;
177 firstRetainedIndex += 1;
178 continue;
179 }
180
181 chunks[firstRetainedIndex] = {
182 ...first,
183 offset: first.offset + overflow,
184 };
185 byteLength -= overflow;
186 overflow = 0;
187 }
188
189 const excessChunks = Math.max(
190 0,
191 chunks.length - firstRetainedIndex - DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_CHUNKS,
192 );
193 for (let index = firstRetainedIndex; index < firstRetainedIndex + excessChunks; index += 1) {
194 const chunk = chunks[index];
195 byteLength -= chunk ? chunk.chunk.byteLength - chunk.offset : 0;
196 }
197 firstRetainedIndex += excessChunks;
198
199 return {
200 ...session,
201 chunks: chunks.slice(firstRetainedIndex),
202 byteLength,
203 };
204}
205
134206const currentDesktopRunId = Effect.gen(function* () {
135207 const annotations = yield* References.CurrentLogAnnotations;
136208 const runId = annotations.runId;
......@@ -345,47 +417,93 @@ const makeBackendOutputLogShape = (
345417 environment: DesktopEnvironment.DesktopEnvironment["Service"],
346418 id: string,
347419 sink: Option.Option<RotatingLogFileWriter>,
348): DesktopBackendOutputLogShape =>
420): Effect.Effect<DesktopBackendOutputLogShape> =>
349421 Option.match(sink, {
350 onNone: () => DesktopBackendOutputLogNoop,
422 onNone: () => Effect.succeed(DesktopBackendOutputLogNoop),
351423 onSome: (logFile) =>
352 ({
353 writeSessionBoundary: Effect.fn("desktop.observability.backendOutput.writeSessionBoundary")(
354 function* ({ phase, details }) {
355 const runId = yield* currentDesktopRunId;
424 Effect.gen(function* () {
425 const sessionRef = yield* Ref.make(Option.none<BackendOutputSession>());
426 const writeFailure = Effect.fn("desktop.observability.backendOutput.writeFailure")(
427 function* (session: BackendOutputSession, details: string) {
356428 yield* writeBackendChildLogRecord(logFile, {
357 message: `backend child process session ${phase.toLowerCase()}`,
358 level: "INFO",
429 message: "backend child process failure output start",
430 level: "ERROR",
359431 annotations: {
360432 component: "desktop-backend-child",
361 runId,
433 runId: session.runId,
362434 instanceId: id,
363 phase,
364 details: sanitizeLogValue(details),
435 phase: "START",
436 details: session.startDetails,
365437 },
366438 });
367 },
368 ),
369 writeOutputChunk: Effect.fn("desktop.observability.backendOutput.writeOutputChunk")(
370 function* (streamName, chunk) {
371 if (environment.isDevelopment) {
372 yield* writeDevelopmentConsoleOutput(streamName, chunk);
439 for (const output of session.chunks) {
440 yield* writeBackendChildLogRecord(logFile, {
441 message: "backend child process output",
442 level: output.streamName === "stderr" ? "ERROR" : "INFO",
443 annotations: {
444 component: "desktop-backend-child",
445 runId: session.runId,
446 instanceId: id,
447 stream: output.streamName,
448 text: textDecoder.decode(output.chunk.subarray(output.offset)),
449 },
450 });
373451 }
374 const runId = yield* currentDesktopRunId;
375452 yield* writeBackendChildLogRecord(logFile, {
376 message: "backend child process output",
377 level: streamName === "stderr" ? "ERROR" : "INFO",
453 message: "backend child process failure output end",
454 level: "ERROR",
378455 annotations: {
379456 component: "desktop-backend-child",
380 runId,
457 runId: session.runId,
381458 instanceId: id,
382 stream: streamName,
383 text: textDecoder.decode(chunk),
459 phase: "END",
460 details: sanitizeLogValue(details),
384461 },
385462 });
386463 },
387 ),
388 }) satisfies DesktopBackendOutputLogShape,
464 );
465 return {
466 beginSession: Effect.fn("desktop.observability.backendOutput.beginSession")(function* ({
467 details,
468 }) {
469 const runId = yield* currentDesktopRunId;
470 yield* Ref.set(
471 sessionRef,
472 Option.some({
473 runId,
474 startDetails: sanitizeLogValue(details),
475 chunks: [],
476 byteLength: 0,
477 }),
478 );
479 }),
480 writeOutputChunk: Effect.fnUntraced(function* (streamName, chunk) {
481 if (environment.isDevelopment) {
482 yield* writeDevelopmentConsoleOutput(streamName, chunk);
483 }
484 yield* Ref.update(
485 sessionRef,
486 Option.map((session) => appendBoundedOutputChunk(session, streamName, chunk)),
487 );
488 }),
489 persistFailureSnapshot: Effect.fn(
490 "desktop.observability.backendOutput.persistFailureSnapshot",
491 )(function* ({ details }) {
492 const session = yield* Ref.get(sessionRef);
493 if (Option.isSome(session)) {
494 yield* writeFailure(session.value, details);
495 }
496 }),
497 persistFailure: Effect.fn("desktop.observability.backendOutput.persistFailure")(
498 function* ({ details }) {
499 const session = yield* Ref.modify(sessionRef, (current) => [current, Option.none()]);
500 if (Option.isNone(session)) return;
501 yield* writeFailure(session.value, details);
502 },
503 ),
504 discardSession: Ref.set(sessionRef, Option.none()),
505 } satisfies DesktopBackendOutputLogShape;
506 }),
389507 });
390508
391509const backendOutputLogFactoryLayer = Layer.effect(
......@@ -412,10 +530,9 @@ const backendOutputLogFactoryLayer = Layer.effect(
412530 const cacheKey = backendLogFilePathForInstance(environment, id);
413531 const cached = cache.get(cacheKey);
414532 if (cached !== undefined) {
415 return Effect.succeed([
416 makeBackendOutputLogShape(environment, id, cached),
417 cache,
418 ] as const);
533 return makeBackendOutputLogShape(environment, id, cached).pipe(
534 Effect.map((outputLog) => [outputLog, cache] as const),
535 );
419536 }
420537 return makeBackendOutputSinkForInstance(environment, id).pipe(
421538 Effect.provideService(FileSystem.FileSystem, fileSystem),
......@@ -424,11 +541,19 @@ const backendOutputLogFactoryLayer = Layer.effect(
424541 Effect.map((sink) => {
425542 const next = new Map(cache);
426543 next.set(cacheKey, sink);
427 return [
428 makeBackendOutputLogShape(environment, id, sink),
429 next as ReadonlyMap<string, Option.Option<RotatingLogFileWriter>>,
430 ] as const;
544 return { sink, next };
431545 }),
546 Effect.flatMap(({ sink, next }) =>
547 makeBackendOutputLogShape(environment, id, sink).pipe(
548 Effect.map(
549 (outputLog) =>
550 [
551 outputLog,
552 next as ReadonlyMap<string, Option.Option<RotatingLogFileWriter>>,
553 ] as const,
554 ),
555 ),
556 ),
432557 );
433558 });
434559
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+95-1
......@@ -52,6 +52,7 @@ function makeEnvironmentLayer(
5252 baseDir: string,
5353 options?: {
5454 readonly appPath?: string;
55 readonly dirname?: string;
5556 readonly isPackaged?: boolean;
5657 readonly devServerUrl?: string;
5758 readonly platform?: NodeJS.Platform;
......@@ -59,7 +60,7 @@ function makeEnvironmentLayer(
5960 },
6061) {
6162 return DesktopEnvironment.layer({
62 dirname: "/repo/apps/desktop/src",
63 dirname: options?.dirname ?? "/repo/apps/desktop/src",
6364 homeDirectory: baseDir,
6465 platform: options?.platform ?? "darwin",
6566 processArch: "x64",
......@@ -464,6 +465,8 @@ describe("DesktopBackendConfiguration", () => {
464465 // both wslhost-forwarded localhost and the distro's eth0 IP.
465466 assert.equal(config.bootstrap.host, "0.0.0.0");
466467 assert.equal(config.bootstrap.tailscaleServeEnabled, false);
468 assert.notProperty(config.bootstrap, "desktopTelemetryFd");
469 assert.notProperty(config.bootstrap, "resourceMonitorPath");
467470 // httpBaseUrl uses the resolved distro IP from the test stub,
468471 // not localhost — the renderer reaches the backend directly to
469472 // avoid relying on wslhost forwarding.
......@@ -713,6 +716,97 @@ describe("DesktopBackendConfiguration", () => {
713716 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
714717 );
715718
719 it.effect("prefers the external packaged resource monitor over the copy inside the asar", () =>
720 Effect.gen(function* () {
721 const fileSystem = yield* FileSystem.FileSystem;
722 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
723 prefix: "t3-desktop-backend-config-test-",
724 });
725 const resourcesPath = `${baseDir}/resources`;
726 const dirname = `${resourcesPath}/app.asar/apps/desktop/dist-electron`;
727 const embeddedMonitorPath = `${resourcesPath}/app.asar/apps/desktop/prod-resources/resource-monitor/t3-resource-monitor`;
728 const monitorPath = `${resourcesPath}/resource-monitor/t3-resource-monitor`;
729 yield* fileSystem.makeDirectory(
730 `${resourcesPath}/app.asar/apps/desktop/prod-resources/resource-monitor`,
731 { recursive: true },
732 );
733 yield* fileSystem.makeDirectory(`${resourcesPath}/resource-monitor`, {
734 recursive: true,
735 });
736 yield* fileSystem.writeFileString(embeddedMonitorPath, "embedded");
737 yield* fileSystem.writeFileString(monitorPath, "binary");
738 yield* fileSystem.chmod(monitorPath, 0o755);
739
740 yield* Effect.gen(function* () {
741 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
742 const config = yield* configuration.resolvePrimary;
743 assert.equal(config.bootstrap.resourceMonitorPath, monitorPath);
744 assert.equal(config.bootstrap.desktopTelemetryFd, 4);
745 assert.equal(config.bootstrap.desktopTelemetryControlFd, 5);
746 }).pipe(
747 Effect.provide(
748 DesktopBackendConfiguration.layer.pipe(
749 Layer.provideMerge(serverExposureLayer),
750 Layer.provideMerge(DesktopAppSettings.layerTest()),
751 Layer.provideMerge(DesktopWslEnvironment.layerTest()),
752 Layer.provideMerge(
753 makeEnvironmentLayer(baseDir, {
754 appPath: `${resourcesPath}/app.asar`,
755 dirname,
756 isPackaged: true,
757 resourcesPath,
758 }),
759 ),
760 ),
761 ),
762 );
763 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
764 );
765
766 it.effect("prefers the release resource monitor when both development builds exist", () =>
767 Effect.gen(function* () {
768 const fileSystem = yield* FileSystem.FileSystem;
769 const path = yield* Path.Path;
770 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
771 prefix: "t3-desktop-backend-config-test-",
772 });
773 const dirname = path.join(baseDir, "apps/desktop/src");
774 const releaseMonitorPath = path.join(
775 baseDir,
776 "native/resource-monitor/target/release/t3-resource-monitor",
777 );
778 const debugMonitorPath = path.join(
779 baseDir,
780 "native/resource-monitor/target/debug/t3-resource-monitor",
781 );
782 yield* fileSystem.makeDirectory(path.dirname(releaseMonitorPath), { recursive: true });
783 yield* fileSystem.makeDirectory(path.dirname(debugMonitorPath), { recursive: true });
784 yield* fileSystem.writeFileString(releaseMonitorPath, "release");
785 yield* fileSystem.writeFileString(debugMonitorPath, "debug");
786
787 yield* Effect.gen(function* () {
788 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
789 const config = yield* configuration.resolvePrimary;
790 assert.equal(config.bootstrap.resourceMonitorPath, releaseMonitorPath);
791 }).pipe(
792 Effect.provide(
793 DesktopBackendConfiguration.layer.pipe(
794 Layer.provideMerge(serverExposureLayer),
795 Layer.provideMerge(DesktopAppSettings.layerTest()),
796 Layer.provideMerge(DesktopWslEnvironment.layerTest()),
797 Layer.provideMerge(
798 makeEnvironmentLayer(baseDir, {
799 dirname,
800 devServerUrl: "http://127.0.0.1:5733",
801 isPackaged: false,
802 }),
803 ),
804 ),
805 ),
806 );
807 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
808 );
809
716810 it.effect("resolvePrimaryLabel reports the local environment on non-Windows platforms", () =>
717811 withHarness(
718812 Effect.gen(function* () {
apps/desktop/src/backend/DesktopBackendConfiguration.ts+56-2
......@@ -141,6 +141,44 @@ const logBackendObservabilitySettingsReadFailure = (
141141 );
142142};
143143
144function resourceMonitorBinaryName(platform: NodeJS.Platform): string {
145 return platform === "win32" ? "t3-resource-monitor.exe" : "t3-resource-monitor";
146}
147
148const resolveResourceMonitorPath = Effect.fn(
149 "desktop.backendConfiguration.resolveResourceMonitorPath",
150)(function* () {
151 const environment = yield* DesktopEnvironment.DesktopEnvironment;
152 const fileSystem = yield* FileSystem.FileSystem;
153 const binaryName = resourceMonitorBinaryName(environment.platform);
154 const candidates = environment.isDevelopment
155 ? [
156 environment.path.join(
157 environment.rootDir,
158 "native/resource-monitor/target/release",
159 binaryName,
160 ),
161 environment.path.join(
162 environment.rootDir,
163 "native/resource-monitor/target/debug",
164 binaryName,
165 ),
166 ]
167 : environment.isPackaged
168 ? [environment.path.join(environment.resourcesPath, "resource-monitor", binaryName)]
169 : environment.resolveResourcePathCandidates(
170 environment.path.join("resource-monitor", binaryName),
171 );
172
173 for (const candidate of candidates) {
174 if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {
175 return Option.some(candidate);
176 }
177 }
178
179 return Option.none<string>();
180});
181
144182const readPersistedBackendObservabilitySettings = Effect.gen(function* () {
145183 const fileSystem = yield* FileSystem.FileSystem;
146184 const environment = yield* DesktopEnvironment.DesktopEnvironment;
......@@ -326,7 +364,9 @@ const buildObservabilityFragment = (observabilitySettings: BackendObservabilityS
326364
327365const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolvePrimary")(
328366 function* (
329 input: SharedBootstrapInput,
367 input: SharedBootstrapInput & {
368 readonly resourceMonitorPath: Option.Option<string>;
369 },
330370 ): Effect.fn.Return<
331371 DesktopBackendManager.DesktopBackendStartConfig,
332372 never,
......@@ -345,6 +385,12 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
345385 desktopBootstrapToken: input.bootstrapToken,
346386 tailscaleServeEnabled: backendExposure.tailscaleServeEnabled,
347387 tailscaleServePort: backendExposure.tailscaleServePort,
388 desktopTelemetryFd: 4,
389 desktopTelemetryControlFd: 5,
390 ...Option.match(input.resourceMonitorPath, {
391 onNone: () => ({}),
392 onSome: (resourceMonitorPath) => ({ resourceMonitorPath }),
393 }),
348394 ...buildObservabilityFragment(input.observabilitySettings),
349395 };
350396
......@@ -411,6 +457,10 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
411457 // inert.
412458 tailscaleServeEnabled: false,
413459 tailscaleServePort: 443,
460 // The packaged sidecar is a Windows executable and cannot run inside the
461 // Linux WSL backend. Keep the field absent instead of passing an unusable
462 // `/mnt/.../*.exe` path; WSL resource telemetry is reported unavailable.
463 // See docs/architecture/resource-telemetry.md.
414464 ...buildObservabilityFragment(input.observabilitySettings),
415465 };
416466
......@@ -621,7 +671,11 @@ export const make = Effect.gen(function* () {
621671
622672 const buildWindowsPrimaryConfig = Effect.gen(function* () {
623673 const shared = yield* sharedInputs;
624 return yield* resolvePrimaryStartConfig(shared).pipe(
674 const resourceMonitorPath = yield* resolveResourceMonitorPath().pipe(
675 Effect.provideService(FileSystem.FileSystem, fileSystem),
676 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
677 );
678 return yield* resolvePrimaryStartConfig({ ...shared, resourceMonitorPath }).pipe(
625679 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
626680 Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure),
627681 );
apps/desktop/src/backend/DesktopBackendManager.test.ts+700-16
......@@ -1,14 +1,17 @@
11import {
22 DesktopBackendBootstrap,
33 type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
4 DesktopTelemetryControlMessage,
45} from "@t3tools/contracts";
56import { assert, describe, it } from "@effect/vitest";
67import * as Deferred from "effect/Deferred";
78import * as Duration from "effect/Duration";
89import * as Effect from "effect/Effect";
910import * as FileSystem from "effect/FileSystem";
11import * as Fiber from "effect/Fiber";
1012import * as Layer from "effect/Layer";
1113import * as Option from "effect/Option";
14import * as PlatformError from "effect/PlatformError";
1215import * as Queue from "effect/Queue";
1316import * as Ref from "effect/Ref";
1417import * as Schema from "effect/Schema";
......@@ -21,10 +24,15 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2124
2225import * as DesktopBackendManager from "./DesktopBackendManager.ts";
2326import * as DesktopObservability from "../app/DesktopObservability.ts";
27import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
2428
2529const decodeDesktopBackendBootstrap = Schema.decodeEffect(
2630 Schema.fromJsonString(DesktopBackendBootstrap),
2731);
32const isBackendProcessError = Schema.is(DesktopBackendManager.BackendProcessError);
33const encodeDesktopTelemetryControl = Schema.encodeSync(
34 Schema.fromJsonString(DesktopTelemetryControlMessage),
35);
2836
2937const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = {
3038 executablePath: "/electron",
......@@ -41,6 +49,8 @@ const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = {
4149 desktopBootstrapToken: "token",
4250 tailscaleServeEnabled: false,
4351 tailscaleServePort: 443,
52 desktopTelemetryFd: 4,
53 desktopTelemetryControlFd: 5,
4454 },
4555 bootstrapDelivery: "fd3",
4656 extendEnv: true,
......@@ -52,14 +62,16 @@ const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = {
5262const configWithObservability: DesktopBackendBootstrapValue = {
5363 ...baseConfig.bootstrap,
5464 tailscaleServeEnabled: true,
65 desktopTelemetryFd: 4,
5566 otlpTracesUrl: "http://127.0.0.1:4318/v1/traces",
5667};
5768
5869function makeProcess(options?: {
59 readonly stdout?: Stream.Stream<Uint8Array>;
60 readonly stderr?: Stream.Stream<Uint8Array>;
61 readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>;
70 readonly stdout?: Stream.Stream<Uint8Array, PlatformError.PlatformError>;
71 readonly stderr?: Stream.Stream<Uint8Array, PlatformError.PlatformError>;
72 readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>;
6273 readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"];
74 readonly getOutputFd?: ChildProcessSpawner.ChildProcessHandle["getOutputFd"];
6375}): ChildProcessSpawner.ChildProcessHandle {
6476 return ChildProcessSpawner.makeHandle({
6577 pid: ChildProcessSpawner.ProcessId(123),
......@@ -71,7 +83,7 @@ function makeProcess(options?: {
7183 kill: options?.kill ?? (() => Effect.void),
7284 stdin: Sink.drain,
7385 getInputFd: () => Sink.drain,
74 getOutputFd: () => Stream.empty,
86 getOutputFd: options?.getOutputFd ?? (() => Stream.empty),
7587 unref: Effect.succeed(Effect.void),
7688 });
7789}
......@@ -112,7 +124,14 @@ interface MakeInstanceInput {
112124 failure: DesktopBackendManager.PreflightFailure,
113125 ) => Effect.Effect<boolean>;
114126 readonly config?: DesktopBackendManager.DesktopBackendStartConfig;
115 readonly configResolve?: Effect.Effect<DesktopBackendManager.DesktopBackendStartConfig>;
127 readonly configResolve?: Effect.Effect<
128 DesktopBackendManager.DesktopBackendStartConfig,
129 PlatformError.PlatformError
130 >;
131 readonly desktopTelemetryStream?: Stream.Stream<Uint8Array>;
132 readonly desktopTelemetryPublisher?: Partial<
133 DesktopTelemetryPublisher.DesktopTelemetryPublisher["Service"]
134 >;
116135}
117136
118137// Helper that constructs a primary backend instance using the factory
......@@ -122,8 +141,11 @@ interface MakeInstanceInput {
122141// to drive the instance's lifecycle.
123142function makeTestInstance(input: MakeInstanceInput) {
124143 const stubLog: DesktopObservability.DesktopBackendOutputLogShape = {
125 writeSessionBoundary: () => Effect.void,
144 beginSession: () => Effect.void,
126145 writeOutputChunk: () => Effect.void,
146 persistFailureSnapshot: () => Effect.void,
147 persistFailure: () => Effect.void,
148 discardSession: Effect.void,
127149 ...input.backendOutputLog,
128150 };
129151 const servicesLayer = Layer.mergeAll(
......@@ -135,6 +157,16 @@ function makeTestInstance(input: MakeInstanceInput) {
135157 Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, {
136158 forInstance: () => Effect.succeed(stubLog),
137159 } satisfies DesktopObservability.DesktopBackendOutputLogFactory["Service"]),
160 Layer.succeed(DesktopTelemetryPublisher.DesktopTelemetryPublisher, {
161 latest: Effect.succeed(Option.none()),
162 changes: Stream.empty,
163 encoded: input.desktopTelemetryStream ?? Stream.empty,
164 handleControl: () => Effect.void,
165 handleControlForSource: (_sourceId, message) =>
166 (input.desktopTelemetryPublisher?.handleControl ?? (() => Effect.void))(message),
167 removeControlSource: () => Effect.void,
168 ...input.desktopTelemetryPublisher,
169 }),
138170 );
139171
140172 const instance = DesktopBackendManager.makeBackendInstance({
......@@ -150,11 +182,12 @@ function makeTestInstance(input: MakeInstanceInput) {
150182}
151183
152184describe("DesktopBackendManager", () => {
153 it.effect("spawns the backend with fd3 bootstrap JSON and reports HTTP readiness", () =>
185 it.effect("spawns the backend with fd3 bootstrap and fd4 telemetry", () =>
154186 Effect.scoped(
155187 Effect.gen(function* () {
156188 let spawnedCommand: ChildProcess.Command | undefined;
157189 let bootstrapJson = "";
190 let telemetryJson = "";
158191 let readyCount = 0;
159192 const ready = yield* Deferred.make<void>();
160193 const exited = yield* Queue.unbounded<void>();
......@@ -169,6 +202,10 @@ describe("DesktopBackendManager", () => {
169202 if (fd3?.type === "input" && fd3.stream) {
170203 bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString);
171204 }
205 const fd4 = command.options.additionalFds?.fd4;
206 if (fd4?.type === "input" && fd4.stream) {
207 telemetryJson = yield* fd4.stream.pipe(Stream.decodeText(), Stream.mkString);
208 }
172209 }
173210
174211 return makeProcess({
......@@ -184,12 +221,14 @@ describe("DesktopBackendManager", () => {
184221 bootstrap: configWithObservability,
185222 },
186223 spawnerLayer,
224 desktopTelemetryStream: Stream.encodeText(
225 Stream.make('{"version":1,"type":"desktopTelemetryHello","electronPid":123}\n'),
226 ),
187227 onReady: Effect.sync(() => {
188228 readyCount += 1;
189229 }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid),
190230 backendOutputLog: {
191 writeSessionBoundary: ({ phase }) =>
192 phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void,
231 persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid),
193232 },
194233 });
195234
......@@ -210,16 +249,400 @@ describe("DesktopBackendManager", () => {
210249 assert.equal(spawnedCommand.options.stderr, "pipe");
211250 assert.equal(spawnedCommand.options.killSignal, "SIGTERM");
212251 assert.isDefined(spawnedCommand.options.forceKillAfter);
252 assert.equal(spawnedCommand.options.additionalFds?.fd4?.type, "input");
253 assert.equal(spawnedCommand.options.additionalFds?.fd5?.type, "output");
213254 assert.equal(
214255 Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)),
215256 2_000,
216257 );
217258
218259 assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability);
260 assert.equal(
261 telemetryJson,
262 '{"version":1,"type":"desktopTelemetryHello","electronPid":123}\n',
263 );
264 }),
265 ),
266 );
267
268 it.effect("preserves the readiness timeout cause and process context", () =>
269 Effect.gen(function* () {
270 const requested = yield* Deferred.make<HttpClientRequest.HttpClientRequest>();
271 const layer = Layer.merge(
272 TestClock.layer(),
273 httpClientLayer((request) =>
274 Deferred.succeed(requested, request).pipe(Effect.andThen(Effect.never)),
275 ),
276 );
277
278 yield* Effect.gen(function* () {
279 const readiness = yield* DesktopBackendManager.waitForHttpReady({
280 executablePath: baseConfig.executablePath,
281 entryPath: baseConfig.entryPath,
282 cwd: baseConfig.cwd,
283 httpBaseUrl: baseConfig.httpBaseUrl,
284 timeout: Duration.millis(50),
285 }).pipe(Effect.flip, Effect.forkChild);
286
287 const request = yield* Deferred.await(requested);
288 assert.equal(request.url, "http://127.0.0.1:3773/.well-known/t3/environment");
289
290 yield* TestClock.adjust(Duration.millis(50));
291 const error = yield* Fiber.join(readiness);
292
293 assert.instanceOf(error, DesktopBackendManager.BackendReadinessTimeoutError);
294 assert.equal(error.executablePath, "/electron");
295 assert.equal(error.entryPath, "/server/bin.mjs");
296 assert.equal(error.cwd, "/server");
297 assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/");
298 assert.equal(error.readinessUrl.href, "http://127.0.0.1:3773/.well-known/t3/environment");
299 assert.equal(error.timeoutMs, 50);
300 assert.isDefined(error.cause);
301 assert.equal(
302 error.message,
303 "Timed out after 50ms waiting for desktop backend readiness at http://127.0.0.1:3773/.well-known/t3/environment.",
304 );
305 }).pipe(Effect.provide(layer));
306 }),
307 );
308
309 it.effect("reports bootstrap encoding failures with stable process context", () =>
310 Effect.gen(function* () {
311 const spawnerLayer = Layer.succeed(
312 ChildProcessSpawner.ChildProcessSpawner,
313 ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")),
314 );
315 const error = yield* DesktopBackendManager.runBackendProcess({
316 ...baseConfig,
317 desktopTelemetryStream: Stream.empty,
318 bootstrap: {
319 ...baseConfig.bootstrap,
320 port: 0,
321 },
322 }).pipe(
323 Effect.flip,
324 Effect.scoped,
325 Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)),
326 );
327
328 if (error._tag !== "BackendProcessBootstrapEncodeError") {
329 return assert.fail(`Expected bootstrap encode error, received ${error._tag}`);
330 }
331 assert.equal(error.executablePath, "/electron");
332 assert.equal(error.entryPath, "/server/bin.mjs");
333 assert.equal(error.cwd, "/server");
334 assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/");
335 assert.isDefined(error.cause);
336 assert.equal(
337 error.message,
338 "Failed to encode the desktop backend bootstrap payload for /server/bin.mjs.",
339 );
340 assert.isTrue(isBackendProcessError(error));
341 }),
342 );
343
344 it.effect("preserves spawn failures without deriving their message from the cause", () =>
345 Effect.gen(function* () {
346 const spawnCause = PlatformError.systemError({
347 _tag: "PermissionDenied",
348 module: "ChildProcessSpawner",
349 method: "spawn",
350 pathOrDescriptor: baseConfig.executablePath,
351 description: "low-level detail that must not become the public message",
352 });
353 const spawnerLayer = Layer.succeed(
354 ChildProcessSpawner.ChildProcessSpawner,
355 ChildProcessSpawner.make(() => Effect.fail(spawnCause)),
356 );
357 const error = yield* DesktopBackendManager.runBackendProcess({
358 ...baseConfig,
359 desktopTelemetryStream: Stream.empty,
360 }).pipe(
361 Effect.flip,
362 Effect.scoped,
363 Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)),
364 );
365
366 if (error._tag !== "BackendProcessSpawnError") {
367 return assert.fail(`Expected backend spawn error, received ${error._tag}`);
368 }
369 assert.equal(error.executablePath, "/electron");
370 assert.equal(error.entryPath, "/server/bin.mjs");
371 assert.equal(error.cwd, "/server");
372 assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/");
373 assert.strictEqual(error.cause, spawnCause);
374 assert.equal(
375 error.message,
376 "Failed to spawn desktop backend entry /server/bin.mjs with /electron.",
377 );
378 assert.notInclude(error.message, spawnCause.message);
379 assert.isTrue(isBackendProcessError(error));
380 }),
381 );
382
383 it.effect("preserves exit-status failures without copying their detail into the message", () =>
384 Effect.gen(function* () {
385 const exitCause = PlatformError.systemError({
386 _tag: "PermissionDenied",
387 module: "ChildProcess",
388 method: "exitCode",
389 description: "exit-status-secret-sentinel",
390 });
391 const spawnerLayer = Layer.succeed(
392 ChildProcessSpawner.ChildProcessSpawner,
393 ChildProcessSpawner.make(() =>
394 Effect.succeed(
395 makeProcess({
396 exitCode: Effect.fail(exitCause),
397 }),
398 ),
399 ),
400 );
401 const error = yield* DesktopBackendManager.runBackendProcess({
402 ...baseConfig,
403 desktopTelemetryStream: Stream.empty,
404 }).pipe(
405 Effect.flip,
406 Effect.scoped,
407 Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)),
408 );
409
410 if (error._tag !== "BackendProcessExitStatusError") {
411 return assert.fail(`Expected backend exit-status error, received ${error._tag}`);
412 }
413 assert.equal(error.pid, 123);
414 assert.equal(error.executablePath, "/electron");
415 assert.equal(error.entryPath, "/server/bin.mjs");
416 assert.equal(error.cwd, "/server");
417 assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/");
418 assert.strictEqual(error.cause, exitCause);
419 assert.equal(error.message, "Failed to read the exit status of desktop backend process 123.");
420 assert.notInclude(error.message, "exit-status-secret-sentinel");
421 assert.isTrue(isBackendProcessError(error));
422 }),
423 );
424
425 it.effect("reports output stream failures with process and stream context", () =>
426 Effect.gen(function* () {
427 const outputCause = PlatformError.systemError({
428 _tag: "BadResource",
429 module: "ChildProcess",
430 method: "stdout",
431 description: "output-stream-secret-sentinel",
432 });
433 const reported = yield* Deferred.make<DesktopBackendManager.BackendProcessOutputError>();
434 const spawnerLayer = Layer.succeed(
435 ChildProcessSpawner.ChildProcessSpawner,
436 ChildProcessSpawner.make(() =>
437 Effect.succeed(
438 makeProcess({
439 stdout: Stream.fail(outputCause),
440 exitCode: Deferred.await(reported).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
441 }),
442 ),
443 ),
444 );
445
446 const exit = yield* DesktopBackendManager.runBackendProcess({
447 ...baseConfig,
448 desktopTelemetryStream: Stream.empty,
449 onOutputFailure: (error) => Deferred.succeed(reported, error).pipe(Effect.asVoid),
450 }).pipe(Effect.scoped, Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)));
451 const error = yield* Deferred.await(reported);
452
453 assert.equal(exit.code.pipe(Option.getOrUndefined), 0);
454 if (error._tag !== "BackendProcessOutputReadError") {
455 return assert.fail(`Expected output read error, received ${error._tag}`);
456 }
457 assert.equal(error.executablePath, "/electron");
458 assert.equal(error.entryPath, "/server/bin.mjs");
459 assert.equal(error.cwd, "/server");
460 assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/");
461 assert.equal(error.pid, 123);
462 assert.equal(error.streamName, "stdout");
463 assert.strictEqual(error.cause, outputCause);
464 assert.equal(error.message, "Failed to read stdout from desktop backend process 123.");
465 assert.notInclude(error.message, "output-stream-secret-sentinel");
466 }),
467 );
468
469 it.effect("reports output handler failures separately from stream read failures", () =>
470 Effect.gen(function* () {
471 const chunk = new TextEncoder().encode("backend output");
472 const nextChunk = new TextEncoder().encode("still draining");
473 const outputCause = new Error("output-handler-secret-sentinel");
474 const reported = yield* Deferred.make<DesktopBackendManager.BackendProcessOutputError>();
475 const drained = yield* Deferred.make<void>();
476 let outputCount = 0;
477 const spawnerLayer = Layer.succeed(
478 ChildProcessSpawner.ChildProcessSpawner,
479 ChildProcessSpawner.make(() =>
480 Effect.succeed(
481 makeProcess({
482 stdout: Stream.make(chunk, nextChunk),
483 exitCode: Deferred.await(drained).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
484 }),
485 ),
486 ),
487 );
488
489 const exit = yield* DesktopBackendManager.runBackendProcess({
490 ...baseConfig,
491 desktopTelemetryStream: Stream.empty,
492 onOutput: () => {
493 outputCount += 1;
494 return outputCount === 1
495 ? Effect.fail(outputCause)
496 : Deferred.succeed(drained, void 0).pipe(Effect.asVoid);
497 },
498 onOutputFailure: (error) => Deferred.succeed(reported, error).pipe(Effect.asVoid),
499 }).pipe(Effect.scoped, Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)));
500 const error = yield* Deferred.await(reported);
501
502 assert.equal(exit.code.pipe(Option.getOrUndefined), 0);
503 if (error._tag !== "BackendProcessOutputHandlingError") {
504 return assert.fail(`Expected output handling error, received ${error._tag}`);
505 }
506 assert.equal(error.executablePath, "/electron");
507 assert.equal(error.entryPath, "/server/bin.mjs");
508 assert.equal(error.cwd, "/server");
509 assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/");
510 assert.equal(error.pid, 123);
511 assert.equal(error.streamName, "stdout");
512 assert.equal(error.chunkByteLength, chunk.byteLength);
513 assert.strictEqual(error.cause, outputCause);
514 assert.equal(
515 error.message,
516 `Failed to handle ${chunk.byteLength} bytes from stdout of desktop backend process 123.`,
517 );
518 assert.notInclude(error.message, "output-handler-secret-sentinel");
519 assert.equal(outputCount, 2);
520 }),
521 );
522
523 it.effect("reports child exit before waiting for trailing output to drain", () =>
524 Effect.scoped(
525 Effect.gen(function* () {
526 const exitObserved = yield* Deferred.make<void>();
527 const finishOutputDrain = yield* Deferred.make<void>();
528 const spawnerLayer = Layer.succeed(
529 ChildProcessSpawner.ChildProcessSpawner,
530 ChildProcessSpawner.make(() =>
531 Effect.succeed(
532 makeProcess({
533 stdout: Stream.fromEffect(
534 Deferred.await(finishOutputDrain).pipe(
535 Effect.as(new TextEncoder().encode("trailing output\n")),
536 ),
537 ),
538 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)),
539 }),
540 ),
541 ),
542 );
543
544 const runFiber = yield* DesktopBackendManager.runBackendProcess({
545 ...baseConfig,
546 desktopTelemetryStream: Stream.empty,
547 onExitObserved: () => Deferred.succeed(exitObserved, void 0).pipe(Effect.asVoid),
548 }).pipe(
549 Effect.provide(Layer.merge(spawnerLayer, healthyHttpClientLayer)),
550 Effect.forkChild,
551 );
552
553 yield* Deferred.await(exitObserved);
554 assert.isUndefined(runFiber.pollUnsafe());
555
556 yield* Deferred.succeed(finishOutputDrain, void 0);
557 assert.equal((yield* Fiber.join(runFiber)).code.pipe(Option.getOrUndefined), 1);
558 }),
559 ),
560 );
561
562 it.effect("continues routing desktop telemetry control messages after an invalid line", () =>
563 Effect.scoped(
564 Effect.gen(function* () {
565 const handled = yield* Deferred.make<boolean>();
566 const controlMessage = encodeDesktopTelemetryControl({
567 version: 1,
568 type: "setDiagnosticsDemand",
569 enabled: true,
570 });
571 const spawnerLayer = Layer.succeed(
572 ChildProcessSpawner.ChildProcessSpawner,
573 ChildProcessSpawner.make(() =>
574 Effect.succeed(
575 makeProcess({
576 getOutputFd: (fd) =>
577 fd === 5
578 ? Stream.encodeText(Stream.make(`not-json\n${controlMessage}\n`))
579 : Stream.empty,
580 exitCode: Deferred.await(handled).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
581 }),
582 ),
583 ),
584 );
585 const instance = yield* makeTestInstance({
586 spawnerLayer,
587 desktopTelemetryPublisher: {
588 handleControl: (message) =>
589 message.type === "setDiagnosticsDemand"
590 ? Deferred.succeed(handled, message.enabled).pipe(Effect.asVoid)
591 : Effect.void,
592 },
593 });
594
595 yield* instance.start;
596 assert.isTrue(yield* Deferred.await(handled));
219597 }),
220598 ),
221599 );
222600
601 it.effect("drains trailing child output before persisting an unexpected exit", () =>
602 Effect.scoped(
603 Effect.gen(function* () {
604 const persistedOutput = yield* Deferred.make<ReadonlyArray<string>>();
605 const outputDrainStarted = yield* Deferred.make<void>();
606 const outputChunks = yield* Ref.make<Array<string>>([]);
607 const spawnerLayer = Layer.succeed(
608 ChildProcessSpawner.ChildProcessSpawner,
609 ChildProcessSpawner.make(() =>
610 Effect.succeed(
611 makeProcess({
612 stdout: Stream.fromEffect(
613 Deferred.succeed(outputDrainStarted, void 0).pipe(
614 Effect.andThen(Effect.sleep(Duration.seconds(1))),
615 Effect.as(new TextEncoder().encode("trailing output\n")),
616 ),
617 ),
618 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)),
619 }),
620 ),
621 ),
622 );
623 const instance = yield* makeTestInstance({
624 spawnerLayer,
625 httpClientLayer: httpClientLayer(() => Effect.never),
626 backendOutputLog: {
627 writeOutputChunk: (_streamName, chunk) =>
628 Ref.update(outputChunks, (current) => [...current, new TextDecoder().decode(chunk)]),
629 persistFailure: () =>
630 Ref.get(outputChunks).pipe(
631 Effect.flatMap((chunks) => Deferred.succeed(persistedOutput, chunks)),
632 Effect.asVoid,
633 ),
634 },
635 });
636
637 yield* instance.start;
638 yield* Deferred.await(outputDrainStarted);
639 yield* TestClock.adjust(Duration.seconds(1));
640
641 assert.deepEqual(yield* Deferred.await(persistedOutput), ["trailing output\n"]);
642 }).pipe(Effect.provide(TestClock.layer())),
643 ),
644 );
645
223646 it.effect("retries HTTP readiness before reporting the backend ready", () =>
224647 Effect.scoped(
225648 Effect.gen(function* () {
......@@ -256,8 +679,7 @@ describe("DesktopBackendManager", () => {
256679 readyCount += 1;
257680 }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid),
258681 backendOutputLog: {
259 writeSessionBoundary: ({ phase }) =>
260 phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void,
682 persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid),
261683 },
262684 });
263685
......@@ -285,9 +707,15 @@ describe("DesktopBackendManager", () => {
285707 let startCount = 0;
286708 let closedCount = 0;
287709 const closed = yield* Deferred.make<void>();
710 const teardownStarted = yield* Deferred.make<void>();
711 const finishTeardown = yield* Deferred.make<void>();
288712 const startedPids = yield* Queue.unbounded<number>();
289713 const ready = yield* Deferred.make<void>();
290714 const backendReadyFlag = yield* Ref.make(false);
715 let shutdownCount = 0;
716 let persistedFailureCount = 0;
717 let discardedSessionCount = 0;
718 let removedTelemetrySources = 0;
291719
292720 const spawnerLayer = Layer.succeed(
293721 ChildProcessSpawner.ChildProcessSpawner,
......@@ -296,9 +724,16 @@ describe("DesktopBackendManager", () => {
296724 const scope = yield* Scope.Scope;
297725 startCount += 1;
298726 yield* Queue.offer(startedPids, 123);
299 const close = Effect.sync(() => {
300 closedCount += 1;
301 }).pipe(Effect.andThen(Deferred.succeed(closed, void 0)), Effect.asVoid);
727 const close = Deferred.succeed(teardownStarted, undefined).pipe(
728 Effect.andThen(Deferred.await(finishTeardown)),
729 Effect.andThen(
730 Effect.sync(() => {
731 closedCount += 1;
732 }),
733 ),
734 Effect.andThen(Deferred.succeed(closed, void 0)),
735 Effect.asVoid,
736 );
302737
303738 yield* Scope.addFinalizer(scope, close);
304739
......@@ -316,7 +751,28 @@ describe("DesktopBackendManager", () => {
316751 Effect.andThen(Deferred.succeed(ready, void 0)),
317752 Effect.asVoid,
318753 ),
319 onShutdown: Ref.set(backendReadyFlag, false),
754 onShutdown: Ref.set(backendReadyFlag, false).pipe(
755 Effect.andThen(
756 Effect.sync(() => {
757 shutdownCount += 1;
758 }),
759 ),
760 ),
761 backendOutputLog: {
762 persistFailure: () =>
763 Effect.sync(() => {
764 persistedFailureCount += 1;
765 }),
766 discardSession: Effect.sync(() => {
767 discardedSessionCount += 1;
768 }),
769 },
770 desktopTelemetryPublisher: {
771 removeControlSource: () =>
772 Effect.sync(() => {
773 removedTelemetrySources += 1;
774 }),
775 },
320776 });
321777 assert.isTrue(Option.isNone(yield* instance.currentConfig));
322778
......@@ -330,12 +786,21 @@ describe("DesktopBackendManager", () => {
330786 assert.equal(runningSnapshot.ready, true);
331787 assert.deepEqual(runningSnapshot.activePid, Option.some(123));
332788
333 yield* instance.stop();
789 const stopFiber = yield* instance.stop().pipe(Effect.forkChild);
790 yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second"));
791 assert.isFalse(yield* Ref.get(backendReadyFlag));
792 assert.equal(shutdownCount, 1);
793 yield* Deferred.succeed(finishTeardown, undefined);
794 yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second"));
334795 assert.equal(startCount, 1);
335796 assert.equal(closedCount, 1);
797 assert.equal(persistedFailureCount, 0);
798 assert.equal(discardedSessionCount, 1);
799 assert.equal(removedTelemetrySources, 1);
336800
337801 const stoppedSnapshot = yield* instance.snapshot;
338802 assert.isFalse(yield* Ref.get(backendReadyFlag));
803 assert.equal(shutdownCount, 1);
339804 assert.equal(stoppedSnapshot.desiredRunning, false);
340805 assert.equal(stoppedSnapshot.ready, false);
341806 assert.equal(Option.isNone(stoppedSnapshot.activePid), true);
......@@ -343,6 +808,213 @@ describe("DesktopBackendManager", () => {
343808 ),
344809 );
345810
811 it.effect("restarts when start is requested during stop teardown", () =>
812 Effect.scoped(
813 Effect.gen(function* () {
814 const starts = yield* Queue.unbounded<number>();
815 const teardownStarted = yield* Deferred.make<void>();
816 const finishTeardown = yield* Deferred.make<void>();
817 let startCount = 0;
818
819 const spawnerLayer = Layer.succeed(
820 ChildProcessSpawner.ChildProcessSpawner,
821 ChildProcessSpawner.make(() =>
822 Effect.gen(function* () {
823 const scope = yield* Scope.Scope;
824 const closed = yield* Deferred.make<void>();
825 startCount += 1;
826 yield* Queue.offer(starts, startCount);
827 if (startCount === 1) {
828 yield* Scope.addFinalizer(
829 scope,
830 Deferred.succeed(teardownStarted, undefined).pipe(
831 Effect.andThen(Deferred.await(finishTeardown)),
832 Effect.andThen(Deferred.succeed(closed, undefined)),
833 Effect.asVoid,
834 ),
835 );
836 } else {
837 yield* Scope.addFinalizer(
838 scope,
839 Deferred.succeed(closed, undefined).pipe(Effect.asVoid),
840 );
841 }
842 return makeProcess({
843 exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
844 });
845 }),
846 ),
847 );
848
849 const instance = yield* makeTestInstance({
850 spawnerLayer,
851 httpClientLayer: httpClientLayer(() => Effect.never),
852 });
853
854 yield* instance.start;
855 assert.equal(yield* Queue.take(starts), 1);
856
857 const stopFiber = yield* instance.stop().pipe(Effect.forkChild);
858 yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second"));
859 yield* instance.start;
860 assert.equal((yield* instance.snapshot).desiredRunning, true);
861
862 yield* Deferred.succeed(finishTeardown, undefined);
863 yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second"));
864 yield* TestClock.adjust(Duration.millis(500));
865
866 assert.equal(yield* Queue.take(starts).pipe(Effect.timeout("1 second")), 2);
867 const restarted = yield* instance.snapshot;
868 assert.equal(restarted.desiredRunning, true);
869 assert.deepEqual(restarted.activePid, Option.some(123));
870 }).pipe(Effect.provide(TestClock.layer())),
871 ),
872 );
873
874 it.effect("retries config resolution after a start request during stop teardown", () =>
875 Effect.scoped(
876 Effect.gen(function* () {
877 const starts = yield* Queue.unbounded<number>();
878 const teardownStarted = yield* Deferred.make<void>();
879 const finishTeardown = yield* Deferred.make<void>();
880 const configAttempts = yield* Ref.make(0);
881 let startCount = 0;
882
883 const configFailure = PlatformError.systemError({
884 _tag: "Unknown",
885 module: "DesktopBackendManager",
886 method: "configResolve",
887 description: "transient configuration failure",
888 });
889 const spawnerLayer = Layer.succeed(
890 ChildProcessSpawner.ChildProcessSpawner,
891 ChildProcessSpawner.make(() =>
892 Effect.gen(function* () {
893 const scope = yield* Scope.Scope;
894 const closed = yield* Deferred.make<void>();
895 startCount += 1;
896 yield* Queue.offer(starts, startCount);
897 if (startCount === 1) {
898 yield* Scope.addFinalizer(
899 scope,
900 Deferred.succeed(teardownStarted, undefined).pipe(
901 Effect.andThen(Deferred.await(finishTeardown)),
902 Effect.andThen(Deferred.succeed(closed, undefined)),
903 Effect.asVoid,
904 ),
905 );
906 } else {
907 yield* Scope.addFinalizer(
908 scope,
909 Deferred.succeed(closed, undefined).pipe(Effect.asVoid),
910 );
911 }
912 return makeProcess({
913 exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
914 });
915 }),
916 ),
917 );
918 const configResolve = Ref.updateAndGet(configAttempts, (attempt) => attempt + 1).pipe(
919 Effect.flatMap((attempt) =>
920 attempt === 2 ? Effect.fail(configFailure) : Effect.succeed(baseConfig),
921 ),
922 );
923 const instance = yield* makeTestInstance({
924 spawnerLayer,
925 configResolve,
926 httpClientLayer: httpClientLayer(() => Effect.never),
927 });
928
929 yield* instance.start;
930 assert.equal(yield* Queue.take(starts), 1);
931
932 const stopFiber = yield* instance.stop().pipe(Effect.forkChild);
933 yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second"));
934 yield* instance.start;
935 yield* Deferred.succeed(finishTeardown, undefined);
936 yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second"));
937
938 const pendingRestart = yield* instance.snapshot;
939 assert.equal(pendingRestart.desiredRunning, true);
940 assert.equal(pendingRestart.restartScheduled, true);
941
942 yield* TestClock.adjust(Duration.seconds(2));
943
944 assert.equal(yield* Queue.take(starts).pipe(Effect.timeout("1 second")), 2);
945 assert.equal(yield* Ref.get(configAttempts), 3);
946 assert.equal((yield* instance.snapshot).desiredRunning, true);
947 }).pipe(Effect.provide(TestClock.layer())),
948 ),
949 );
950
951 it.effect("keeps a timed-out run active until its process exits", () =>
952 Effect.scoped(
953 Effect.gen(function* () {
954 const starts = yield* Queue.unbounded<number>();
955 const teardownStarted = yield* Deferred.make<void>();
956 const finishTeardown = yield* Deferred.make<void>();
957 let startCount = 0;
958
959 const spawnerLayer = Layer.succeed(
960 ChildProcessSpawner.ChildProcessSpawner,
961 ChildProcessSpawner.make(() =>
962 Effect.gen(function* () {
963 const scope = yield* Scope.Scope;
964 const closed = yield* Deferred.make<void>();
965 startCount += 1;
966 yield* Queue.offer(starts, startCount);
967 if (startCount === 1) {
968 yield* Scope.addFinalizer(
969 scope,
970 Deferred.succeed(teardownStarted, undefined).pipe(
971 Effect.andThen(Deferred.await(finishTeardown)),
972 Effect.andThen(Deferred.succeed(closed, undefined)),
973 Effect.asVoid,
974 ),
975 );
976 } else {
977 yield* Scope.addFinalizer(
978 scope,
979 Deferred.succeed(closed, undefined).pipe(Effect.asVoid),
980 );
981 }
982 return makeProcess({
983 exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
984 });
985 }),
986 ),
987 );
988
989 const instance = yield* makeTestInstance({
990 spawnerLayer,
991 httpClientLayer: httpClientLayer(() => Effect.never),
992 });
993
994 yield* instance.start;
995 assert.equal(yield* Queue.take(starts), 1);
996
997 const stopFiber = yield* instance
998 .stop({ timeout: Duration.millis(100) })
999 .pipe(Effect.forkChild);
1000 yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second"));
1001 yield* instance.start;
1002 yield* TestClock.adjust(Duration.millis(100));
1003 yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second"));
1004
1005 assert.equal(startCount, 1);
1006 const timedOut = yield* instance.snapshot;
1007 assert.equal(timedOut.desiredRunning, true);
1008 assert.deepEqual(timedOut.activePid, Option.some(123));
1009
1010 yield* Deferred.succeed(finishTeardown, undefined);
1011 yield* TestClock.adjust(Duration.millis(500));
1012
1013 assert.equal(yield* Queue.take(starts).pipe(Effect.timeout("1 second")), 2);
1014 }).pipe(Effect.provide(TestClock.layer())),
1015 ),
1016 );
1017
3461018 it.effect("does not notify shutdown before the first start has prior state", () =>
3471019 Effect.scoped(
3481020 Effect.gen(function* () {
......@@ -383,6 +1055,7 @@ describe("DesktopBackendManager", () => {
3831055 Effect.scoped(
3841056 Effect.gen(function* () {
3851057 const starts = yield* Queue.unbounded<number>();
1058 const failures = yield* Queue.unbounded<string>();
3861059 let startCount = 0;
3871060
3881061 const spawnerLayer = Layer.succeed(
......@@ -402,11 +1075,15 @@ describe("DesktopBackendManager", () => {
4021075 const instance = yield* makeTestInstance({
4031076 spawnerLayer,
4041077 httpClientLayer: httpClientLayer(() => Effect.never),
1078 backendOutputLog: {
1079 persistFailure: ({ details }) => Queue.offer(failures, details).pipe(Effect.asVoid),
1080 },
4051081 });
4061082
4071083 yield* instance.start;
4081084
4091085 assert.equal(yield* Queue.take(starts), 1);
1086 assert.equal(yield* Queue.take(failures), "pid=123 code=1");
4101087
4111088 yield* TestClock.adjust(Duration.millis(499));
4121089 assert.equal(yield* Queue.size(starts), 0);
......@@ -657,6 +1334,13 @@ describe("DesktopBackendManager", () => {
6571334 yield* instance.start;
6581335
6591336 assert.equal(yield* Queue.take(starts), 1);
1337 let restartScheduled = false;
1338 while (!restartScheduled) {
1339 restartScheduled = (yield* instance.snapshot).restartScheduled;
1340 if (!restartScheduled) {
1341 yield* Effect.yieldNow;
1342 }
1343 }
6601344
6611345 yield* instance.start;
6621346 assert.equal(yield* Queue.take(starts), 2);
apps/desktop/src/backend/DesktopBackendManager.ts+466-144
......@@ -33,7 +33,6 @@ import * as FileSystem from "effect/FileSystem";
3333import * as Option from "effect/Option";
3434import * as PlatformError from "effect/PlatformError";
3535import * as Ref from "effect/Ref";
36import * as Result from "effect/Result";
3736import * as Schedule from "effect/Schedule";
3837import * as Schema from "effect/Schema";
3938import * as Semaphore from "effect/Semaphore";
......@@ -46,10 +45,13 @@ import {
4645 DesktopBackendBootstrap,
4746 type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
4847 PRIMARY_LOCAL_ENVIRONMENT_ID,
48 DesktopTelemetryControlMessage,
49 type DesktopTelemetryControlMessage as DesktopTelemetryControlMessageValue,
4950} from "@t3tools/contracts";
5051import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/httpReadiness";
5152
5253import * as DesktopObservability from "../app/DesktopObservability.ts";
54import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
5355
5456const INITIAL_RESTART_DELAY = Duration.millis(500);
5557const MAX_RESTART_DELAY = Duration.seconds(10);
......@@ -62,7 +64,10 @@ const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1);
6264const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100);
6365const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1);
6466const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(2);
67const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5);
6568const BACKEND_READINESS_PATH = "/.well-known/t3/environment";
69const { logWarning: logBackendProcessWarning } =
70 DesktopObservability.makeComponentLogger("desktop-backend-process");
6671
6772type BackendProcessLayerServices = ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient;
6873
......@@ -70,13 +75,17 @@ type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope;
7075
7176export type BackendProcessOutputStream = "stdout" | "stderr";
7277
73export type DesktopBackendBootstrapDelivery = "fd3" | "stdin";
74
75export interface DesktopBackendStartConfig {
78export interface BackendProcessContext {
7679 readonly executablePath: string;
77 readonly args: ReadonlyArray<string>;
7880 readonly entryPath: string;
7981 readonly cwd: string;
82 readonly httpBaseUrl: URL;
83}
84
85export type DesktopBackendBootstrapDelivery = "fd3" | "stdin";
86
87export interface DesktopBackendStartConfig extends BackendProcessContext {
88 readonly args: ReadonlyArray<string>;
8089 readonly env: Record<string, string | undefined>;
8190 // When true the spawner merges the desktop process.env on top of `env`;
8291 // when false `env` is passed verbatim. WSL mode opts out so a leaking
......@@ -106,55 +115,122 @@ export interface PreflightFailure {
106115interface BackendProcessExit {
107116 readonly code: Option.Option<number>;
108117 readonly reason: string;
109 readonly result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>;
110118}
111119
112export class BackendTimeoutError extends Schema.TaggedErrorClass<BackendTimeoutError>()(
113 "BackendTimeoutError",
120const backendProcessContextSchema = {
121 executablePath: Schema.String,
122 entryPath: Schema.String,
123 cwd: Schema.String,
124 httpBaseUrl: Schema.URL,
125};
126
127export class BackendReadinessTimeoutError extends Schema.TaggedErrorClass<BackendReadinessTimeoutError>()(
128 "BackendReadinessTimeoutError",
114129 {
115 url: Schema.instanceOf(URL),
130 ...backendProcessContextSchema,
131 readinessUrl: Schema.URL,
132 timeoutMs: Schema.Number,
133 cause: Schema.Defect(),
116134 },
117135) {
118 override get message() {
119 return `Timed out waiting for backend readiness at ${this.url.href}.`;
136 override get message(): string {
137 return `Timed out after ${this.timeoutMs}ms waiting for desktop backend readiness at ${this.readinessUrl.href}.`;
120138 }
121139}
122140
123class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass<BackendProcessBootstrapEncodeError>()(
141export class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass<BackendProcessBootstrapEncodeError>()(
124142 "BackendProcessBootstrapEncodeError",
125143 {
126 entryPath: Schema.String,
144 ...backendProcessContextSchema,
127145 cause: Schema.Defect(),
128146 },
129147) {
130 override get message() {
148 override get message(): string {
131149 return `Failed to encode the desktop backend bootstrap payload for ${this.entryPath}.`;
132150 }
133151}
134152
135class BackendProcessSpawnError extends Schema.TaggedErrorClass<BackendProcessSpawnError>()(
153export class BackendProcessSpawnError extends Schema.TaggedErrorClass<BackendProcessSpawnError>()(
136154 "BackendProcessSpawnError",
137155 {
138 executablePath: Schema.String,
156 ...backendProcessContextSchema,
157 cause: Schema.Defect(),
158 },
159) {
160 override get message(): string {
161 return `Failed to spawn desktop backend entry ${this.entryPath} with ${this.executablePath}.`;
162 }
163}
164
165export class BackendProcessOutputReadError extends Schema.TaggedErrorClass<BackendProcessOutputReadError>()(
166 "BackendProcessOutputReadError",
167 {
168 ...backendProcessContextSchema,
169 pid: Schema.Number,
170 streamName: Schema.Literals(["stdout", "stderr"]),
171 cause: Schema.Defect(),
172 },
173) {
174 override get message(): string {
175 return `Failed to read ${this.streamName} from desktop backend process ${this.pid}.`;
176 }
177}
178
179export class BackendProcessOutputHandlingError extends Schema.TaggedErrorClass<BackendProcessOutputHandlingError>()(
180 "BackendProcessOutputHandlingError",
181 {
182 ...backendProcessContextSchema,
183 pid: Schema.Number,
184 streamName: Schema.Literals(["stdout", "stderr"]),
185 chunkByteLength: Schema.Number,
186 cause: Schema.Defect(),
187 },
188) {
189 override get message(): string {
190 return `Failed to handle ${this.chunkByteLength} bytes from ${this.streamName} of desktop backend process ${this.pid}.`;
191 }
192}
193
194export type BackendProcessOutputError =
195 | BackendProcessOutputReadError
196 | BackendProcessOutputHandlingError;
197
198export class BackendProcessExitStatusError extends Schema.TaggedErrorClass<BackendProcessExitStatusError>()(
199 "BackendProcessExitStatusError",
200 {
201 ...backendProcessContextSchema,
202 pid: Schema.Number,
139203 cause: Schema.Defect(),
140204 },
141205) {
142 override get message() {
143 return `Failed to spawn the desktop backend process at ${this.executablePath}.`;
206 override get message(): string {
207 return `Failed to read the exit status of desktop backend process ${this.pid}.`;
144208 }
145209}
146210
147type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError;
211export const BackendProcessError = Schema.Union([
212 BackendProcessBootstrapEncodeError,
213 BackendProcessSpawnError,
214 BackendProcessExitStatusError,
215]);
216export type BackendProcessError = typeof BackendProcessError.Type;
148217
149218interface RunBackendProcessOptions extends DesktopBackendStartConfig {
219 readonly desktopTelemetryStream: Stream.Stream<Uint8Array>;
220 readonly onDesktopTelemetryControl?: (
221 message: DesktopTelemetryControlMessageValue,
222 ) => Effect.Effect<void>;
150223 readonly readinessTimeout?: Duration.Duration;
224 readonly outputDrainTimeout?: Duration.Duration;
151225 readonly onStarted?: (pid: number) => Effect.Effect<void>;
226 readonly onExitObserved?: () => Effect.Effect<void>;
152227 readonly onReady?: () => Effect.Effect<void>;
153 readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect<void>;
228 readonly onReadinessFailure?: (error: BackendReadinessTimeoutError) => Effect.Effect<void>;
154229 readonly onOutput?: (
155230 streamName: BackendProcessOutputStream,
156231 chunk: Uint8Array,
157 ) => Effect.Effect<void>;
232 ) => Effect.Effect<void, Error>;
233 readonly onOutputFailure?: (error: BackendProcessOutputError) => Effect.Effect<void>;
158234}
159235
160236export interface DesktopBackendSnapshot {
......@@ -225,6 +301,8 @@ interface ActiveBackendRun {
225301 readonly scope: Scope.Closeable;
226302 readonly fiber: Option.Option<Fiber.Fiber<void, never>>;
227303 readonly pid: Option.Option<number>;
304 readonly exitObserved: boolean;
305 readonly stopRequested: boolean;
228306}
229307
230308interface BackendManagerState {
......@@ -266,76 +344,130 @@ const calculateRestartDelay = (attempt: number): Duration.Duration =>
266344
267345const closeRun = (
268346 run: ActiveBackendRun,
347 parentScope: Scope.Scope,
269348 options?: { readonly timeout?: Duration.Duration },
270): Effect.Effect<void> => {
349): Effect.Effect<boolean> => {
271350 const waitForFiber = Option.match(run.fiber, {
272351 onNone: () => Effect.void,
273352 onSome: (fiber) => Fiber.await(fiber).pipe(Effect.asVoid),
274353 });
275354 const close = Scope.close(run.scope, Exit.void).pipe(Effect.andThen(waitForFiber));
355 const timeout = options?.timeout;
276356
277 return (
278 options?.timeout ? close.pipe(Effect.timeoutOption(options.timeout), Effect.asVoid) : close
279 ).pipe(Effect.ignore);
357 if (!timeout) {
358 return close.pipe(Effect.as(true));
359 }
360
361 return Effect.forkIn(close, parentScope).pipe(
362 Effect.flatMap((closeFiber) =>
363 Fiber.await(closeFiber).pipe(Effect.timeoutOption(timeout), Effect.map(Option.isSome)),
364 ),
365 );
280366};
281367
282const waitForHttpReady = (
283 baseUrl: URL,
284 timeout: Duration.Duration,
285): Effect.Effect<void, BackendTimeoutError, HttpClient.HttpClient> => {
286 const readinessUrl = new URL(BACKEND_READINESS_PATH, baseUrl);
368export const waitForHttpReady = (
369 options: BackendProcessContext & { readonly timeout: Duration.Duration },
370): Effect.Effect<void, BackendReadinessTimeoutError, HttpClient.HttpClient> => {
371 const readinessUrl = new URL(BACKEND_READINESS_PATH, options.httpBaseUrl);
287372 return waitForHttpReadyShared({
288 baseUrl: baseUrl.href,
373 baseUrl: options.httpBaseUrl.href,
289374 path: BACKEND_READINESS_PATH,
290 timeoutMs: Duration.toMillis(timeout),
375 timeoutMs: Duration.toMillis(options.timeout),
291376 intervalMs: Duration.toMillis(DEFAULT_BACKEND_READINESS_INTERVAL),
292377 probeTimeoutMs: Duration.toMillis(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT),
293 makeError: () => new BackendTimeoutError({ url: readinessUrl }),
378 makeError: ({ cause }) =>
379 new BackendReadinessTimeoutError({
380 executablePath: options.executablePath,
381 entryPath: options.entryPath,
382 cwd: options.cwd,
383 httpBaseUrl: options.httpBaseUrl,
384 readinessUrl,
385 timeoutMs: Duration.toMillis(options.timeout),
386 cause,
387 }),
294388 });
295389};
296390
297function describeProcessExit(
298 result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>,
299): BackendProcessExit {
300 if (Result.isSuccess(result)) {
301 return {
302 code: Option.some(result.success),
303 reason: `code=${result.success}`,
304 result,
305 };
306 }
307
308 return {
309 code: Option.none(),
310 reason: result.failure.message,
311 result,
312 };
313}
314
315391function drainBackendOutput(
392 context: BackendProcessContext & { readonly pid: number },
316393 streamName: BackendProcessOutputStream,
317394 stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>,
318 onOutput: (streamName: BackendProcessOutputStream, chunk: Uint8Array) => Effect.Effect<void>,
395 onOutput: (
396 streamName: BackendProcessOutputStream,
397 chunk: Uint8Array,
398 ) => Effect.Effect<void, Error>,
399 onOutputFailure: (error: BackendProcessOutputError) => Effect.Effect<void>,
319400): Effect.Effect<void> {
320401 return stream.pipe(
321 Stream.runForEach((chunk) => onOutput(streamName, chunk)),
322 Effect.ignore,
402 Stream.mapError(
403 (cause) =>
404 new BackendProcessOutputReadError({
405 ...context,
406 streamName,
407 cause,
408 }),
409 ),
410 Stream.runForEach((chunk) =>
411 onOutput(streamName, chunk).pipe(
412 Effect.mapError(
413 (cause) =>
414 new BackendProcessOutputHandlingError({
415 ...context,
416 streamName,
417 chunkByteLength: chunk.byteLength,
418 cause,
419 }),
420 ),
421 Effect.catchTag("BackendProcessOutputHandlingError", onOutputFailure),
422 ),
423 ),
424 Effect.catchTags({
425 BackendProcessOutputReadError: onOutputFailure,
426 }),
323427 );
324428}
325429
326430const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap));
431const decodeDesktopTelemetryControlLine = Schema.decodeUnknownEffect(
432 Schema.fromJsonString(DesktopTelemetryControlMessage),
433);
327434
328const runBackendProcess = Effect.fn("runBackendProcess")(function* (
435export const runBackendProcess = Effect.fn("runBackendProcess")(function* (
329436 options: RunBackendProcessOptions,
330437): Effect.fn.Return<BackendProcessExit, BackendProcessError, BackendProcessRunRequirements> {
331438 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
332439 const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe(
333440 Effect.mapError(
334 (cause) => new BackendProcessBootstrapEncodeError({ entryPath: options.entryPath, cause }),
441 (cause) =>
442 new BackendProcessBootstrapEncodeError({
443 executablePath: options.executablePath,
444 entryPath: options.entryPath,
445 cwd: options.cwd,
446 httpBaseUrl: options.httpBaseUrl,
447 cause,
448 }),
335449 ),
336450 );
337451 const onOutput = options.onOutput ?? (() => Effect.void);
338452 const bootstrapStream = Stream.encodeText(Stream.make(`${bootstrapJson}\n`));
453 const additionalFds: Record<`fd${number}`, ChildProcess.AdditionalFdConfig> = {};
454 if (options.bootstrapDelivery === "fd3") {
455 additionalFds.fd3 = {
456 type: "input",
457 stream: bootstrapStream,
458 };
459 if (options.bootstrap.desktopTelemetryFd !== undefined) {
460 additionalFds[`fd${options.bootstrap.desktopTelemetryFd}`] = {
461 type: "input",
462 stream: options.desktopTelemetryStream,
463 };
464 }
465 if (options.bootstrap.desktopTelemetryControlFd !== undefined) {
466 additionalFds[`fd${options.bootstrap.desktopTelemetryControlFd}`] = {
467 type: "output",
468 };
469 }
470 }
339471 const command = ChildProcess.make(options.executablePath, options.args, {
340472 cwd: options.cwd,
341473 env: options.env,
......@@ -350,34 +482,131 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* (
350482 // wsl.exe drops additional file descriptors when forwarding to the Linux
351483 // side, so the WSL spawn path delivers the bootstrap envelope via stdin
352484 // (`--bootstrap-fd 0`) instead.
353 ...(options.bootstrapDelivery === "fd3"
354 ? { additionalFds: { fd3: { type: "input" as const, stream: bootstrapStream } } }
355 : {}),
485 ...(options.bootstrapDelivery === "fd3" ? { additionalFds } : {}),
356486 });
357487
358 const handle = yield* spawner
359 .spawn(command)
360 .pipe(
361 Effect.mapError(
362 (cause) => new BackendProcessSpawnError({ executablePath: options.executablePath, cause }),
363 ),
364 );
488 const handle = yield* spawner.spawn(command).pipe(
489 Effect.mapError(
490 (cause) =>
491 new BackendProcessSpawnError({
492 executablePath: options.executablePath,
493 entryPath: options.entryPath,
494 cwd: options.cwd,
495 httpBaseUrl: options.httpBaseUrl,
496 cause,
497 }),
498 ),
499 );
500 const outputFibers: Array<Fiber.Fiber<void, never>> = [];
365501
366502 yield* options.onStarted?.(handle.pid) ?? Effect.void;
503 if (
504 options.bootstrap.desktopTelemetryControlFd !== undefined &&
505 options.onDesktopTelemetryControl !== undefined
506 ) {
507 const controlFd = options.bootstrap.desktopTelemetryControlFd;
508 const handleControl = options.onDesktopTelemetryControl;
509 yield* handle.getOutputFd(controlFd).pipe(
510 Stream.decodeText(),
511 Stream.splitLines,
512 Stream.filter((line) => line.trim().length > 0),
513 Stream.runForEach((line) =>
514 decodeDesktopTelemetryControlLine(line).pipe(
515 Effect.flatMap(handleControl),
516 Effect.catchCause((cause) =>
517 logBackendProcessWarning("ignored invalid desktop telemetry control message", {
518 fd: controlFd,
519 cause: Cause.pretty(cause),
520 }),
521 ),
522 ),
523 ),
524 Effect.catchCause((cause) =>
525 logBackendProcessWarning("desktop telemetry control stream stopped", {
526 fd: controlFd,
527 cause: Cause.pretty(cause),
528 }),
529 ),
530 Effect.ensuring(
531 handleControl({
532 version: 1,
533 type: "setDiagnosticsDemand",
534 enabled: false,
535 }),
536 ),
537 Effect.forkScoped,
538 );
539 }
367540 if (options.captureOutput) {
368 yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped);
369 yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped);
541 const outputContext = {
542 executablePath: options.executablePath,
543 entryPath: options.entryPath,
544 cwd: options.cwd,
545 httpBaseUrl: options.httpBaseUrl,
546 pid: Number(handle.pid),
547 };
548 const onOutputFailure = options.onOutputFailure ?? (() => Effect.void);
549 outputFibers.push(
550 yield* drainBackendOutput(
551 outputContext,
552 "stdout",
553 handle.stdout,
554 onOutput,
555 onOutputFailure,
556 ).pipe(Effect.forkScoped),
557 yield* drainBackendOutput(
558 outputContext,
559 "stderr",
560 handle.stderr,
561 onOutput,
562 onOutputFailure,
563 ).pipe(Effect.forkScoped),
564 );
370565 }
371 yield* waitForHttpReady(
372 options.httpBaseUrl,
373 options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT,
374 ).pipe(
566 yield* waitForHttpReady({
567 executablePath: options.executablePath,
568 entryPath: options.entryPath,
569 cwd: options.cwd,
570 httpBaseUrl: options.httpBaseUrl,
571 timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT,
572 }).pipe(
375573 Effect.tap(() => options.onReady?.() ?? Effect.void),
376 Effect.catch((error) => options.onReadinessFailure?.(error) ?? Effect.void),
574 Effect.catchTags({
575 BackendReadinessTimeoutError: (error) => options.onReadinessFailure?.(error) ?? Effect.void,
576 }),
377577 Effect.forkScoped,
378578 );
379579
380 return describeProcessExit(yield* Effect.result(handle.exitCode));
580 const exit = yield* handle.exitCode.pipe(
581 Effect.mapError(
582 (cause) =>
583 new BackendProcessExitStatusError({
584 executablePath: options.executablePath,
585 entryPath: options.entryPath,
586 cwd: options.cwd,
587 httpBaseUrl: options.httpBaseUrl,
588 pid: Number(handle.pid),
589 cause,
590 }),
591 ),
592 Effect.exit,
593 );
594 yield* options.onExitObserved?.() ?? Effect.void;
595 yield* Effect.forEach(outputFibers, Fiber.await, {
596 concurrency: "unbounded",
597 discard: true,
598 }).pipe(
599 Effect.timeout(options.outputDrainTimeout ?? DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT),
600 Effect.ignore,
601 );
602 if (Exit.isFailure(exit)) {
603 return yield* Effect.failCause(exit.cause);
604 }
605 const exitCode = exit.value;
606 return {
607 code: Option.some(exitCode),
608 reason: `code=${exitCode}`,
609 } satisfies BackendProcessExit;
381610});
382611
383612// Factory for one pooled backend instance. The returned instance owns
......@@ -394,12 +623,14 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
394623 | ChildProcessSpawner.ChildProcessSpawner
395624 | HttpClient.HttpClient
396625 | DesktopObservability.DesktopBackendOutputLogFactory
626 | DesktopTelemetryPublisher.DesktopTelemetryPublisher
397627 | Scope.Scope
398628> {
399629 const parentScope = yield* Scope.Scope;
400630 const fileSystem = yield* FileSystem.FileSystem;
401631 const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory;
402632 const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id);
633 const desktopTelemetryPublisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher;
403634 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
404635 const httpClient = yield* HttpClient.HttpClient;
405636 const state = yield* Ref.make(initialState);
......@@ -444,6 +675,12 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
444675 Effect.gen(function* () {
445676 const current = yield* Ref.get(state);
446677 if (Option.isSome(current.active)) {
678 if (!current.desiredRunning) {
679 yield* Ref.update(state, (latest) => ({
680 ...latest,
681 desiredRunning: true,
682 }));
683 }
447684 return;
448685 }
449686
......@@ -462,6 +699,9 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
462699 Effect.option,
463700 );
464701 if (Option.isNone(config)) {
702 if (current.desiredRunning) {
703 yield* scheduleRestart("failed to generate desktop backend configuration");
704 }
465705 return;
466706 }
467707 const entryExists = yield* fileSystem
......@@ -561,6 +801,8 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
561801 scope: runScope,
562802 fiber: Option.none(),
563803 pid: Option.none(),
804 exitObserved: false,
805 stopRequested: false,
564806 } satisfies ActiveBackendRun),
565807 nextRunId: latest.nextRunId + 1,
566808 },
......@@ -571,54 +813,70 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
571813 ) {
572814 yield* mutex.withPermits(1)(
573815 Effect.gen(function* () {
574 const { isCurrentRun, nextState, pid } = yield* Ref.modify(
575 state,
576 (
577 latest,
578 ): readonly [
579 {
580 readonly isCurrentRun: boolean;
581 readonly nextState: BackendManagerState;
582 readonly pid: Option.Option<number>;
583 },
584 BackendManagerState,
585 ] => {
586 const currentRun = Option.getOrUndefined(latest.active);
587 if (currentRun?.id !== runId) {
816 const { isCurrentRun, nextState, pid, exitObserved, stopRequested, wasReady } =
817 yield* Ref.modify(
818 state,
819 (
820 latest,
821 ): readonly [
822 {
823 readonly isCurrentRun: boolean;
824 readonly nextState: BackendManagerState;
825 readonly pid: Option.Option<number>;
826 readonly exitObserved: boolean;
827 readonly stopRequested: boolean;
828 readonly wasReady: boolean;
829 },
830 BackendManagerState,
831 ] => {
832 const currentRun = Option.getOrUndefined(latest.active);
833 if (currentRun?.id !== runId) {
834 return [
835 {
836 isCurrentRun: false,
837 nextState: latest,
838 pid: Option.none<number>(),
839 exitObserved: false,
840 stopRequested: false,
841 wasReady: false,
842 },
843 latest,
844 ] as const;
845 }
846
847 const next = {
848 ...latest,
849 active: Option.none<ActiveBackendRun>(),
850 ready: false,
851 };
588852 return [
589853 {
590 isCurrentRun: false,
591 nextState: latest,
592 pid: Option.none<number>(),
854 isCurrentRun: true,
855 nextState: next,
856 pid: currentRun.pid,
857 exitObserved: currentRun.exitObserved,
858 stopRequested: currentRun.stopRequested,
859 wasReady: latest.ready,
593860 },
594 latest,
861 next,
595862 ] as const;
596 }
597
598 const next = {
599 ...latest,
600 active: Option.none<ActiveBackendRun>(),
601 ready: false,
602 };
603 return [
604 {
605 isCurrentRun: true,
606 nextState: next,
607 pid: currentRun.pid,
608 },
609 next,
610 ] as const;
611 },
612 );
863 },
864 );
613865
614866 if (isCurrentRun) {
867 yield* desktopTelemetryPublisher.removeControlSource(spec.id);
615868 if (Option.isSome(pid)) {
616 yield* backendOutputLog.writeSessionBoundary({
617 phase: "END",
618 details: `pid=${pid.value} ${reason}`,
619 });
869 if (exitObserved && !stopRequested) {
870 yield* backendOutputLog.persistFailure({
871 details: `pid=${pid.value} ${reason}`,
872 });
873 } else {
874 yield* backendOutputLog.discardSession;
875 }
876 }
877 if (wasReady) {
878 yield* spec.onShutdown?.() ?? Effect.void;
620879 }
621 yield* spec.onShutdown?.() ?? Effect.void;
622880 }
623881
624882 if (isCurrentRun && nextState.desiredRunning) {
......@@ -630,16 +888,23 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
630888
631889 const program = runBackendProcess({
632890 ...config.value,
891 desktopTelemetryStream: desktopTelemetryPublisher.encoded,
892 onDesktopTelemetryControl: (message) =>
893 desktopTelemetryPublisher.handleControlForSource(spec.id, message),
633894 onStarted: Effect.fn("desktop.backendInstance.onStarted")(function* (pid) {
634895 yield* updateActiveRun(runId, (run) => ({
635896 ...run,
636897 pid: Option.some(pid),
637898 }));
638 yield* backendOutputLog.writeSessionBoundary({
639 phase: "START",
899 yield* backendOutputLog.beginSession({
640900 details: `pid=${pid} port=${config.value.bootstrap.port} cwd=${config.value.cwd}`,
641901 });
642902 }),
903 onExitObserved: () =>
904 updateActiveRun(runId, (run) => ({
905 ...run,
906 exitObserved: true,
907 })),
643908 onReady: Effect.fn("desktop.backendInstance.onReady")(function* () {
644909 const isCurrentRun = yield* Ref.modify(state, (latest) => {
645910 const activeRun = Option.getOrUndefined(latest.active);
......@@ -662,10 +927,16 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
662927
663928 yield* spec.onReady?.(config.value.httpBaseUrl) ?? Effect.void;
664929 }),
665 onReadinessFailure: (error) =>
666 logInstanceWarning("backend readiness check failed during bootstrap", {
667 error: error.message,
668 }),
930 onReadinessFailure: Effect.fn("desktop.backendInstance.onReadinessFailure")(
931 function* (error) {
932 yield* logInstanceWarning("backend readiness check failed during bootstrap", {
933 error: error.message,
934 });
935 yield* backendOutputLog.persistFailureSnapshot({
936 details: error.message,
937 });
938 },
939 ),
669940 onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk),
670941 }).pipe(
671942 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
......@@ -750,41 +1021,92 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
7501021 const stop = Effect.fn("desktop.backendInstance.stop")(function* (options?: {
7511022 readonly timeout?: Duration.Duration;
7521023 }) {
753 const { active, restartFiber } = yield* mutex.withPermits(1)(
1024 const { active, restartFiber, notifyShutdown } = yield* mutex.withPermits(1)(
7541025 Effect.gen(function* () {
755 const result = yield* Ref.modify(state, (latest) => [
756 {
757 active: latest.active,
758 restartFiber: latest.restartFiber,
759 },
760 {
761 ...latest,
762 desiredRunning: false,
763 ready: false,
764 active: Option.none<ActiveBackendRun>(),
765 restartFiber: Option.none<Fiber.Fiber<void, never>>(),
766 },
767 ]);
768 // Ignore failures from spec.onShutdown so a downstream throw
769 // can't abort the rest of stop(). Ref.modify above already
770 // flipped state to "no active run / no restart fiber", and the
771 // physical cleanup (Fiber.interrupt + closeRun) runs after the
772 // mutex releases. If onShutdown were allowed to propagate, both
773 // would be skipped and the child process + restart fiber would
774 // be orphaned while state claimed nothing was running — the
775 // next start() would then spawn a second backend on top.
776 yield* (spec.onShutdown?.() ?? Effect.void).pipe(Effect.ignore);
1026 const result = yield* Ref.modify(state, (latest) => {
1027 const active = Option.map(latest.active, (run) =>
1028 run.exitObserved ? run : { ...run, stopRequested: true },
1029 );
1030 return [
1031 {
1032 active,
1033 restartFiber: latest.restartFiber,
1034 notifyShutdown: latest.ready,
1035 },
1036 {
1037 ...latest,
1038 desiredRunning: false,
1039 ready: false,
1040 active,
1041 restartFiber: Option.none<Fiber.Fiber<void, never>>(),
1042 },
1043 ] as const;
1044 });
7771045 return result;
7781046 }),
7791047 );
7801048
1049 if (notifyShutdown) {
1050 yield* (spec.onShutdown?.() ?? Effect.void).pipe(Effect.ignore);
1051 }
7811052 yield* Option.match(restartFiber, {
7821053 onNone: () => Effect.void,
7831054 onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid),
7841055 });
7851056 yield* Option.match(active, {
7861057 onNone: () => Effect.void,
787 onSome: (run) => closeRun(run, options),
1058 onSome: (run) =>
1059 Effect.gen(function* () {
1060 const closed = yield* closeRun(run, parentScope, options);
1061 if (!closed) {
1062 return;
1063 }
1064 const cleanup = yield* mutex.withPermits(1)(
1065 Ref.modify(
1066 state,
1067 (
1068 latest,
1069 ): readonly [
1070 {
1071 readonly needsCleanup: boolean;
1072 readonly shouldStart: boolean;
1073 },
1074 BackendManagerState,
1075 ] => {
1076 const current = Option.getOrUndefined(latest.active);
1077 if (current?.id !== run.id) {
1078 return [
1079 {
1080 needsCleanup: false,
1081 shouldStart:
1082 latest.desiredRunning &&
1083 Option.isNone(latest.active) &&
1084 Option.isNone(latest.restartFiber),
1085 },
1086 latest,
1087 ];
1088 }
1089 return [
1090 {
1091 needsCleanup: true,
1092 shouldStart: latest.desiredRunning,
1093 },
1094 {
1095 ...latest,
1096 active: Option.none<ActiveBackendRun>(),
1097 },
1098 ];
1099 },
1100 ),
1101 );
1102 if (cleanup.needsCleanup) {
1103 yield* desktopTelemetryPublisher.removeControlSource(spec.id);
1104 yield* backendOutputLog.discardSession;
1105 }
1106 if (cleanup.shouldStart) {
1107 yield* start;
1108 }
1109 }),
7881110 });
7891111 });
7901112
apps/desktop/src/backend/DesktopBackendPool.test.ts+14-1
......@@ -5,11 +5,13 @@ import * as FileSystem from "effect/FileSystem";
55import * as Layer from "effect/Layer";
66import * as Option from "effect/Option";
77import * as Ref from "effect/Ref";
8import * as Stream from "effect/Stream";
89import { HttpClient } from "effect/unstable/http";
910import { ChildProcessSpawner } from "effect/unstable/process";
1011
1112import * as DesktopObservability from "../app/DesktopObservability.ts";
1213import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
14import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
1315import * as ElectronDialog from "../electron/ElectronDialog.ts";
1416import * as DesktopWindow from "../window/DesktopWindow.ts";
1517import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
......@@ -56,10 +58,21 @@ function makePoolLayer(
5658 Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, {
5759 forInstance: () =>
5860 Effect.succeed({
59 writeSessionBoundary: () => Effect.void,
61 beginSession: () => Effect.void,
6062 writeOutputChunk: () => Effect.void,
63 persistFailureSnapshot: () => Effect.void,
64 persistFailure: () => Effect.void,
65 discardSession: Effect.void,
6166 } satisfies DesktopObservability.DesktopBackendOutputLogShape),
6267 } satisfies DesktopObservability.DesktopBackendOutputLogFactory["Service"]),
68 Layer.succeed(DesktopTelemetryPublisher.DesktopTelemetryPublisher, {
69 latest: Effect.succeed(Option.none()),
70 changes: Stream.empty,
71 encoded: Stream.empty,
72 handleControl: () => Effect.void,
73 handleControlForSource: () => Effect.void,
74 removeControlSource: () => Effect.void,
75 }),
6376 Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, {
6477 resolvePrimary: Effect.die("unexpected primary config resolve"),
6578 resolvePrimaryLabel: Ref.get(labelRef),
apps/desktop/src/backend/DesktopBackendPool.ts+3-1
......@@ -97,6 +97,7 @@ import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
9797import * as DesktopBackendManager from "./DesktopBackendManager.ts";
9898import * as DesktopObservability from "../app/DesktopObservability.ts";
9999import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
100import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
100101import * as DesktopWindow from "../window/DesktopWindow.ts";
101102import * as ElectronDialog from "../electron/ElectronDialog.ts";
102103
......@@ -176,7 +177,8 @@ export type BackendInstanceFactoryRequirements =
176177 | FileSystem.FileSystem
177178 | ChildProcessSpawner.ChildProcessSpawner
178179 | HttpClient.HttpClient
179 | DesktopObservability.DesktopBackendOutputLogFactory;
180 | DesktopObservability.DesktopBackendOutputLogFactory
181 | DesktopTelemetryPublisher.DesktopTelemetryPublisher;
180182
181183interface ActiveRegisteredInstance {
182184 readonly _tag: "Active";
apps/desktop/src/electron/ElectronApp.ts+2
......@@ -57,6 +57,7 @@ export class ElectronApp extends Context.Service<
5757 ) => Effect.Effect<void>;
5858 readonly setAppUserModelId: (id: string) => Effect.Effect<void>;
5959 readonly requestSingleInstanceLock: Effect.Effect<boolean>;
60 readonly getAppMetrics: Effect.Effect<ReadonlyArray<Electron.ProcessMetric>>;
6061 readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect<boolean>;
6162 readonly setAsDefaultProtocolClient: (
6263 protocol: string,
......@@ -153,6 +154,7 @@ export const make = ElectronApp.of({
153154 Electron.app.setAppUserModelId(id);
154155 }),
155156 requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()),
157 getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()),
156158 isDefaultProtocolClient: (protocol) =>
157159 Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)),
158160 setAsDefaultProtocolClient: (protocol, path, args) =>
apps/desktop/src/electron/ElectronPowerMonitor.ts created+89
......@@ -0,0 +1,89 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Scope from "effect/Scope";
5
6import * as Electron from "electron";
7
8export type ElectronThermalState = ReturnType<Electron.PowerMonitor["getCurrentThermalState"]>;
9export type ElectronIdleState = ReturnType<Electron.PowerMonitor["getSystemIdleState"]>;
10
11export class ElectronPowerMonitor extends Context.Service<
12 ElectronPowerMonitor,
13 {
14 readonly isOnBatteryPower: Effect.Effect<boolean>;
15 readonly getSystemIdleTime: Effect.Effect<number>;
16 readonly getSystemIdleState: (idleThresholdSeconds: number) => Effect.Effect<ElectronIdleState>;
17 readonly getCurrentThermalState: Effect.Effect<ElectronThermalState>;
18 readonly onSimpleEvent: (
19 eventName: "lock-screen" | "unlock-screen" | "on-ac" | "on-battery" | "suspend" | "resume",
20 listener: () => void,
21 ) => Effect.Effect<void, never, Scope.Scope>;
22 readonly onThermalStateChange: (
23 listener: (state: ElectronThermalState) => void,
24 ) => Effect.Effect<void, never, Scope.Scope>;
25 readonly onSpeedLimitChange: (
26 listener: (limit: number) => void,
27 ) => Effect.Effect<void, never, Scope.Scope>;
28 }
29>()("@t3tools/desktop/electron/ElectronPowerMonitor") {}
30
31const onSimpleEvent: ElectronPowerMonitor["Service"]["onSimpleEvent"] = (eventName, listener) =>
32 Effect.acquireRelease(
33 Effect.sync(() => {
34 Electron.powerMonitor.on(eventName as any, listener as any);
35 }),
36 () =>
37 Effect.sync(() => {
38 Electron.powerMonitor.removeListener(eventName as any, listener as any);
39 }),
40 ).pipe(Effect.asVoid);
41
42const onThermalStateChange: ElectronPowerMonitor["Service"]["onThermalStateChange"] = (
43 listener,
44) => {
45 const wrapped = (
46 event: Electron.Event<Electron.PowerMonitorThermalStateChangeEventParams>,
47 ): void => {
48 listener(event.state);
49 };
50 return Effect.acquireRelease(
51 Effect.sync(() => {
52 Electron.powerMonitor.on("thermal-state-change", wrapped);
53 }),
54 () =>
55 Effect.sync(() => {
56 Electron.powerMonitor.removeListener("thermal-state-change", wrapped);
57 }),
58 ).pipe(Effect.asVoid);
59};
60
61const onSpeedLimitChange: ElectronPowerMonitor["Service"]["onSpeedLimitChange"] = (listener) => {
62 const wrapped = (
63 event: Electron.Event<Electron.PowerMonitorSpeedLimitChangeEventParams>,
64 ): void => {
65 listener(event.limit);
66 };
67 return Effect.acquireRelease(
68 Effect.sync(() => {
69 Electron.powerMonitor.on("speed-limit-change", wrapped);
70 }),
71 () =>
72 Effect.sync(() => {
73 Electron.powerMonitor.removeListener("speed-limit-change", wrapped);
74 }),
75 ).pipe(Effect.asVoid);
76};
77
78export const make = ElectronPowerMonitor.of({
79 isOnBatteryPower: Effect.sync(() => Electron.powerMonitor.isOnBatteryPower()),
80 getSystemIdleTime: Effect.sync(() => Electron.powerMonitor.getSystemIdleTime()),
81 getSystemIdleState: (idleThresholdSeconds) =>
82 Effect.sync(() => Electron.powerMonitor.getSystemIdleState(idleThresholdSeconds)),
83 getCurrentThermalState: Effect.sync(() => Electron.powerMonitor.getCurrentThermalState()),
84 onSimpleEvent,
85 onThermalStateChange,
86 onSpeedLimitChange,
87});
88
89export const layer = Layer.succeed(ElectronPowerMonitor, make);
apps/desktop/src/main.ts+4
......@@ -24,6 +24,7 @@ import * as DesktopIpc from "./ipc/DesktopIpc.ts";
2424import * as ElectronApp from "./electron/ElectronApp.ts";
2525import * as ElectronDialog from "./electron/ElectronDialog.ts";
2626import * as ElectronMenu from "./electron/ElectronMenu.ts";
27import * as ElectronPowerMonitor from "./electron/ElectronPowerMonitor.ts";
2728import * as ElectronProtocol from "./electron/ElectronProtocol.ts";
2829import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts";
2930import * as ElectronShell from "./electron/ElectronShell.ts";
......@@ -52,6 +53,7 @@ import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts";
5253import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts";
5354import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";
5455import * as DesktopState from "./app/DesktopState.ts";
56import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts";
5557import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
5658import * as BrowserSession from "./preview/BrowserSession.ts";
5759import * as PreviewManager from "./preview/Manager.ts";
......@@ -113,6 +115,7 @@ const electronLayer = Layer.mergeAll(
113115 ElectronApp.layer,
114116 ElectronDialog.layer,
115117 ElectronMenu.layer,
118 ElectronPowerMonitor.layer,
116119 ElectronProtocol.layer,
117120 ElectronSafeStorage.layer,
118121 ElectronShell.layer,
......@@ -160,6 +163,7 @@ const desktopBackendLayer = DesktopBackendPool.layer.pipe(
160163 Layer.provideMerge(DesktopAppIdentity.layer),
161164 Layer.provideMerge(DesktopBackendConfiguration.layer),
162165 Layer.provideMerge(DesktopWslEnvironment.layer),
166 Layer.provideMerge(DesktopTelemetryPublisher.layer),
163167 Layer.provideMerge(desktopWindowLayer),
164168);
165169
apps/desktop/src/preview/Manager.test.ts+86
......@@ -982,6 +982,92 @@ describe("PreviewManager", () => {
982982 ),
983983 );
984984
985 effectIt.effect("emits debugger screencast frames only while recording is active", () =>
986 withManager((manager) =>
987 Effect.gen(function* () {
988 let debuggerMessage:
989 | ((event: unknown, method: string, params: Record<string, unknown>) => void)
990 | undefined;
991 const capturePage = vi.fn(async () => ({
992 toJPEG: () => Buffer.from("scheduled-recording-frame"),
993 getSize: () => ({ width: 1280, height: 720 }),
994 }));
995 const sendCommand = vi.fn(async (method: string) =>
996 method === "Runtime.evaluate" ? { result: { value: null } } : undefined,
997 );
998 fromId.mockReturnValue({
999 id: 42,
1000 isDestroyed: () => false,
1001 getType: () => "webview",
1002 getURL: () => "https://example.com",
1003 getTitle: () => "Example",
1004 isLoading: () => false,
1005 isDevToolsOpened: () => false,
1006 getZoomFactor: () => 1,
1007 setZoomFactor: vi.fn(),
1008 on: vi.fn(),
1009 off: vi.fn(),
1010 ipc: { on: vi.fn(), off: vi.fn() },
1011 send: webviewSend,
1012 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
1013 setWindowOpenHandler: vi.fn(),
1014 debugger: {
1015 isAttached: () => false,
1016 attach: vi.fn(),
1017 sendCommand,
1018 on: vi.fn(
1019 (
1020 event: string,
1021 listener: (event: unknown, method: string, params: Record<string, unknown>) => void,
1022 ) => {
1023 if (event === "message") debuggerMessage = listener;
1024 },
1025 ),
1026 off: vi.fn(),
1027 },
1028 capturePage,
1029 } as never);
1030 const recordingFrames: DesktopPreviewRecordingFrame[] = [];
1031
1032 yield* manager.subscribeRecordingFrames((frame) =>
1033 Effect.sync(() => {
1034 recordingFrames.push(frame);
1035 }),
1036 );
1037 yield* manager.createTab("tab_screencast_guard");
1038 yield* manager.registerWebview("tab_screencast_guard", 42);
1039 yield* manager.automationEvaluate("tab_screencast_guard", { expression: "null" });
1040
1041 debuggerMessage?.({}, "Page.screencastFrame", {
1042 sessionId: 1,
1043 data: "inactive-frame",
1044 metadata: { deviceWidth: 1280, deviceHeight: 720 },
1045 });
1046 yield* Effect.yieldNow;
1047 expect(recordingFrames).toHaveLength(0);
1048
1049 yield* manager.startRecording("tab_screencast_guard");
1050 recordingFrames.length = 0;
1051 debuggerMessage?.({}, "Page.screencastFrame", {
1052 sessionId: 2,
1053 data: "active-frame",
1054 metadata: { deviceWidth: 1280, deviceHeight: 720 },
1055 });
1056 yield* Effect.yieldNow;
1057
1058 expect(recordingFrames).toEqual([
1059 expect.objectContaining({
1060 tabId: "tab_screencast_guard",
1061 data: "active-frame",
1062 width: 1280,
1063 height: 720,
1064 }),
1065 ]);
1066 yield* manager.stopRecording("tab_screencast_guard");
1067 }),
1068 ),
1069 );
1070
9851071 effectIt.effect("shares background frame capture between recording and picture-in-picture", () =>
9861072 withManager((manager) =>
9871073 Effect.gen(function* () {
apps/desktop/src/preview/Manager.ts+55-6
......@@ -671,10 +671,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
671671 ),
672672 );
673673
674 const tabIdForWebContents = Effect.fnUntraced(function* (webContentsId: number) {
675 const tabs = yield* SynchronizedRef.get(tabsRef);
676 return (
677 Array.from(tabs.entries()).find(([, tab]) => tab.webContentsId === webContentsId)?.[0] ?? null
678 );
679 });
680
674681 const pushBounded = <A>(buffer: ReadonlyArray<A>, entry: A): ReadonlyArray<A> =>
675682 [...buffer, entry].slice(-DIAGNOSTIC_BUFFER_LIMIT);
676683
677 const captureDiagnosticMessage = Effect.fn("PreviewManager.captureDiagnosticMessage")(function* (
684 const captureDiagnosticMessage = Effect.fnUntraced(function* (
678685 webContentsId: number,
679686 method: string,
680687 params: Record<string, unknown>,
......@@ -852,11 +859,53 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
852859 const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () {
853860 const semaphore = yield* Semaphore.make(1);
854861 const scope = yield* Scope.fork(parentScope, "sequential");
855 const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")(
856 function* (method: string, params: Record<string, unknown>) {
857 yield* captureDiagnosticMessage(wc.id, method, params);
858 },
859 );
862 const handleDebuggerMessage = Effect.fnUntraced(function* (
863 method: string,
864 params: Record<string, unknown>,
865 ) {
866 if (method === "Page.screencastFrame") {
867 const sessionId = params["sessionId"];
868 if (typeof sessionId === "number") {
869 yield* attemptPromise(
870 {
871 operation: "ackScreencastFrame",
872 webContentsId: wc.id,
873 },
874 () => wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }),
875 ).pipe(Effect.ignore);
876 }
877 const tabId = yield* tabIdForWebContents(wc.id);
878 const metadata =
879 typeof params["metadata"] === "object" && params["metadata"] !== null
880 ? (params["metadata"] as Record<string, unknown>)
881 : {};
882 if (tabId && typeof params["data"] === "string") {
883 const captureSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(
884 tabId,
885 );
886 if (captureSession?.consumers.has("recording")) {
887 const receivedAt = yield* currentIso;
888 const listeners = yield* Ref.get(recordingFrameListenersRef);
889 const frame: DesktopPreviewRecordingFrame = {
890 tabId,
891 data: params["data"],
892 width:
893 typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0,
894 height:
895 typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0,
896 receivedAt,
897 };
898 yield* Effect.forEach(
899 listeners,
900 (listener) =>
901 deliverEvent("recording-frame", frame.tabId, () => listener(frame)),
902 { discard: true },
903 );
904 }
905 }
906 }
907 yield* captureDiagnosticMessage(wc.id, method, params);
908 });
860909 const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => {
861910 runFork(handleDebuggerMessage(method, params));
862911 };
apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts created+388
......@@ -0,0 +1,388 @@
1import { DesktopHostTelemetryMessage } from "@t3tools/contracts";
2import { assert, describe, it } from "@effect/vitest";
3import * as Deferred from "effect/Deferred";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import * as Fiber from "effect/Fiber";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Ref from "effect/Ref";
11import * as Schema from "effect/Schema";
12import * as Scope from "effect/Scope";
13import * as Stream from "effect/Stream";
14import * as TestClock from "effect/testing/TestClock";
15
16import type * as Electron from "electron";
17
18import * as ElectronApp from "../electron/ElectronApp.ts";
19import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts";
20import * as DesktopTelemetryPublisher from "./DesktopTelemetryPublisher.ts";
21
22function makeElectronAppLayer(
23 metrics: ReadonlyArray<Electron.ProcessMetric>,
24 onMetricsRead: () => void = () => undefined,
25) {
26 return Layer.succeed(ElectronApp.ElectronApp, {
27 metadata: Effect.die("unexpected metadata read"),
28 name: Effect.succeed("T3 Code"),
29 whenReady: Effect.void,
30 quit: Effect.void,
31 exit: () => Effect.void,
32 relaunch: () => Effect.void,
33 setPath: () => Effect.void,
34 setName: () => Effect.void,
35 setAboutPanelOptions: () => Effect.void,
36 setAppUserModelId: () => Effect.void,
37 requestSingleInstanceLock: Effect.succeed(true),
38 getAppMetrics: Effect.sync(() => {
39 onMetricsRead();
40 return metrics;
41 }),
42 isDefaultProtocolClient: () => Effect.succeed(false),
43 setAsDefaultProtocolClient: () => Effect.succeed(true),
44 setDesktopName: () => Effect.void,
45 setDockIcon: () => Effect.void,
46 appendCommandLineSwitch: () => Effect.void,
47 onBeforeQuitForUpdate: () => Effect.void,
48 on: () => Effect.void,
49 } satisfies ElectronApp.ElectronApp["Service"]);
50}
51
52describe("DesktopTelemetryPublisher", () => {
53 it.effect("stops when its scope closes during an Electron telemetry sample", () =>
54 Effect.gen(function* () {
55 const pollStarted = yield* Deferred.make<void>();
56 const blockPoll = yield* Deferred.make<void>();
57 const powerLayer = Layer.succeed(
58 ElectronPowerMonitor.ElectronPowerMonitor,
59 ElectronPowerMonitor.ElectronPowerMonitor.of({
60 isOnBatteryPower: Effect.succeed(false),
61 getSystemIdleTime: Deferred.succeed(pollStarted, undefined).pipe(
62 Effect.andThen(Deferred.await(blockPoll)),
63 Effect.as(0),
64 ),
65 getSystemIdleState: () => Effect.succeed("active"),
66 getCurrentThermalState: Effect.succeed("nominal"),
67 onSimpleEvent: () => Effect.void,
68 onThermalStateChange: () => Effect.void,
69 onSpeedLimitChange: () => Effect.void,
70 }),
71 );
72 const layer = DesktopTelemetryPublisher.layer.pipe(
73 Layer.provide(Layer.mergeAll(makeElectronAppLayer([]), powerLayer)),
74 );
75 const scope = yield* Scope.make();
76
77 yield* Layer.buildWithScope(layer, scope);
78 yield* Deferred.await(pollStarted);
79
80 const closeFiber = yield* Scope.close(scope, Exit.void).pipe(Effect.forkDetach);
81 yield* Effect.yieldNow;
82 yield* Effect.yieldNow;
83
84 assert.isDefined(closeFiber.pollUnsafe());
85 }),
86 );
87
88 it.effect("publishes Electron metrics and event-driven power state over NDJSON", () =>
89 Effect.gen(function* () {
90 const onBattery = yield* Ref.make(false);
91 const systemIdleState = yield* Ref.make<ElectronPowerMonitor.ElectronIdleState>("active");
92 let beforeSystemIdleState: Effect.Effect<void> = Effect.void;
93 let metricsReadCount = 0;
94 const simpleListeners = new Map<string, () => void>();
95 let thermalListener: ((state: ElectronPowerMonitor.ElectronThermalState) => void) | null =
96 null;
97 let speedLimitListener: ((limit: number) => void) | null = null;
98 const metrics = [
99 {
100 pid: 4_242,
101 type: "Browser",
102 creationTime: 1_000.75,
103 name: "electron",
104 cpu: {
105 percentCPUUsage: 12.5,
106 cumulativeCPUUsage: 3.25,
107 idleWakeupsPerSecond: 7,
108 },
109 memory: {
110 workingSetSize: 2_048,
111 peakWorkingSetSize: 4_096,
112 },
113 } as Electron.ProcessMetric,
114 ];
115 const powerLayer = Layer.succeed(
116 ElectronPowerMonitor.ElectronPowerMonitor,
117 ElectronPowerMonitor.ElectronPowerMonitor.of({
118 isOnBatteryPower: Ref.get(onBattery),
119 getSystemIdleTime: Effect.succeed(5),
120 getSystemIdleState: () =>
121 beforeSystemIdleState.pipe(Effect.andThen(Ref.get(systemIdleState))),
122 getCurrentThermalState: Effect.succeed("nominal"),
123 onSimpleEvent: (eventName, listener) =>
124 Effect.sync(() => {
125 simpleListeners.set(eventName, listener);
126 }),
127 onThermalStateChange: (listener) =>
128 Effect.sync(() => {
129 thermalListener = listener;
130 }),
131 onSpeedLimitChange: (listener) =>
132 Effect.sync(() => {
133 speedLimitListener = listener;
134 }),
135 }),
136 );
137 const layer = DesktopTelemetryPublisher.layer.pipe(
138 Layer.provide(
139 Layer.mergeAll(
140 makeElectronAppLayer(metrics, () => {
141 metricsReadCount += 1;
142 }),
143 powerLayer,
144 ),
145 ),
146 );
147
148 yield* Effect.gen(function* () {
149 const publisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher;
150 const encoded = yield* publisher.encoded.pipe(Stream.take(2), Stream.runCollect);
151 const decoder = new TextDecoder();
152 const decodeMessage = Schema.decodeUnknownEffect(
153 Schema.fromJsonString(DesktopHostTelemetryMessage),
154 );
155 const messages = yield* Effect.forEach(encoded, (bytes) =>
156 decodeMessage(decoder.decode(bytes).trim()),
157 );
158
159 assert.equal(messages[0]?.type, "desktopTelemetryHello");
160 assert.equal(messages[0]?.electronPid, process.pid);
161 const initialSnapshot = messages[1];
162 if (initialSnapshot?.type !== "desktopTelemetry") {
163 return assert.fail("Expected the second telemetry message to be a snapshot.");
164 }
165 assert.deepEqual(initialSnapshot.electronProcesses, []);
166 assert.equal(initialSnapshot.electronPid, process.pid);
167 assert.equal(metricsReadCount, 0);
168
169 const nextSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(Effect.forkChild);
170 yield* Effect.yieldNow;
171 yield* publisher.handleControl({
172 version: 1,
173 type: "setDiagnosticsDemand",
174 enabled: true,
175 });
176 const demandedSnapshot = Option.getOrThrow(yield* Fiber.join(nextSnapshotFiber));
177 assert.equal(demandedSnapshot.electronProcesses[0]?.pid, 4_242);
178 assert.equal(demandedSnapshot.electronProcesses[0]?.creationTimeMs, 1_001);
179 assert.equal(demandedSnapshot.electronProcesses[0]?.cpuPercent, 12.5);
180 assert.equal(demandedSnapshot.electronProcesses[0]?.workingSetBytes, 2_048 * 1_024);
181 assert.equal(metricsReadCount, 1);
182 yield* publisher.handleControlForSource("secondary-backend", {
183 version: 1,
184 type: "setDiagnosticsDemand",
185 enabled: true,
186 });
187 yield* publisher.handleControl({
188 version: 1,
189 type: "setDiagnosticsDemand",
190 enabled: false,
191 });
192 yield* publisher.handleControlForSource("old-backend", {
193 version: 1,
194 type: "setDiagnosticsDemand",
195 enabled: true,
196 });
197 yield* publisher.handleControlForSource("secondary-backend", {
198 version: 1,
199 type: "setDiagnosticsDemand",
200 enabled: false,
201 });
202 yield* Effect.all(
203 [
204 publisher.removeControlSource("old-backend"),
205 publisher.handleControlForSource("replacement-backend", {
206 version: 1,
207 type: "setDiagnosticsDemand",
208 enabled: true,
209 }),
210 ],
211 { concurrency: "unbounded", discard: true },
212 );
213
214 const batterySnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(
215 Effect.forkChild,
216 );
217 yield* Effect.yieldNow;
218 simpleListeners.get("on-battery")?.();
219 const batterySnapshot = Option.getOrThrow(yield* Fiber.join(batterySnapshotFiber));
220 assert.equal(batterySnapshot.power.onBattery, "true");
221 yield* Ref.set(onBattery, true);
222
223 const metricsAfterBatteryEvent = metricsReadCount;
224 yield* TestClock.adjust(Duration.millis(4_999));
225 assert.equal(metricsReadCount, metricsAfterBatteryEvent);
226 yield* TestClock.adjust(Duration.millis(1));
227 assert.equal(metricsReadCount, metricsAfterBatteryEvent + 1);
228 assert.equal((yield* publisher.latest).pipe(Option.getOrThrow).power.onBattery, "true");
229
230 yield* Ref.set(onBattery, false);
231 const metricsBeforePolledAc = metricsReadCount;
232 yield* TestClock.adjust(Duration.seconds(5));
233 assert.equal(metricsReadCount, metricsBeforePolledAc + 1);
234 assert.equal((yield* publisher.latest).pipe(Option.getOrThrow).power.onBattery, "false");
235 yield* TestClock.adjust(Duration.seconds(1));
236 assert.equal(metricsReadCount, metricsBeforePolledAc + 2);
237
238 const suspendedSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(
239 Effect.forkChild,
240 );
241 yield* Effect.yieldNow;
242 simpleListeners.get("suspend")?.();
243 const suspendedSnapshot = Option.getOrThrow(yield* Fiber.join(suspendedSnapshotFiber));
244 assert.isTrue(suspendedSnapshot.power.suspended);
245
246 const constrainedSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(
247 Effect.forkChild,
248 );
249 yield* Effect.yieldNow;
250 thermalListener?.("serious");
251 const constrainedSnapshot = Option.getOrThrow(yield* Fiber.join(constrainedSnapshotFiber));
252 assert.equal(constrainedSnapshot.power.thermalState, "serious");
253 assert.isTrue(constrainedSnapshot.power.suspended);
254
255 const metricsAfterThermalEvent = metricsReadCount;
256 const recoveredSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(
257 Effect.forkChild,
258 );
259 yield* TestClock.adjust(Duration.millis(14_999));
260 assert.equal(metricsReadCount, metricsAfterThermalEvent);
261 yield* TestClock.adjust(Duration.millis(1));
262 assert.equal(metricsReadCount, metricsAfterThermalEvent + 1);
263 const recoveredSnapshot = Option.getOrThrow(yield* Fiber.join(recoveredSnapshotFiber));
264 assert.isFalse(recoveredSnapshot.power.suspended);
265
266 const speedLimitSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(
267 Effect.forkChild,
268 );
269 yield* Effect.yieldNow;
270 speedLimitListener?.(65);
271 const speedLimitSnapshot = Option.getOrThrow(yield* Fiber.join(speedLimitSnapshotFiber));
272 assert.equal(Option.getOrNull(speedLimitSnapshot.speedLimitPercent), 65);
273
274 const encodedSpeedLimit = yield* publisher.encoded.pipe(Stream.take(2), Stream.runCollect);
275 const decodedSpeedLimit = yield* decodeMessage(decoder.decode(encodedSpeedLimit[1]).trim());
276 if (decodedSpeedLimit.type !== "desktopTelemetry") {
277 return assert.fail("Expected the encoded telemetry message to be a snapshot.");
278 }
279 assert.equal(Option.getOrNull(decodedSpeedLimit.speedLimitPercent), 65);
280 assert.equal(decodedSpeedLimit.electronProcesses[0]?.pid, 4_242);
281 assert.equal(decodedSpeedLimit.electronProcesses[0]?.creationTimeMs, 1_001);
282
283 const metricsBeforeSecondaryOnlySample = metricsReadCount;
284 yield* TestClock.adjust(Duration.seconds(15));
285 assert.equal(metricsReadCount, metricsBeforeSecondaryOnlySample + 1);
286 assert.equal(
287 (yield* publisher.latest).pipe(Option.getOrThrow).electronProcesses[0]?.pid,
288 4_242,
289 );
290
291 const stoppedSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(
292 Effect.forkChild,
293 );
294 yield* Effect.yieldNow;
295 yield* publisher.handleControlForSource("replacement-backend", {
296 version: 1,
297 type: "setDiagnosticsDemand",
298 enabled: false,
299 });
300 const stoppedSnapshot = Option.getOrThrow(yield* Fiber.join(stoppedSnapshotFiber));
301 assert.deepEqual(stoppedSnapshot.electronProcesses, []);
302 const metricsAfterStopping = metricsReadCount;
303 const configuredSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(
304 Effect.forkChild,
305 );
306 yield* Effect.yieldNow;
307 yield* publisher.handleControl({
308 version: 1,
309 type: "setHostPowerIntervals",
310 activeIntervalMs: 7_000,
311 idleIntervalMs: 11_000,
312 });
313 const configuredSequence = Option.getOrThrow(
314 yield* Fiber.join(configuredSnapshotFiber),
315 ).sequence;
316
317 yield* TestClock.adjust(Duration.millis(6_999));
318 assert.equal(
319 (yield* publisher.latest).pipe(Option.getOrThrow).sequence,
320 configuredSequence,
321 );
322 assert.equal(metricsReadCount, metricsAfterStopping);
323 yield* TestClock.adjust(Duration.millis(1));
324 assert.equal(
325 (yield* publisher.latest).pipe(Option.getOrThrow).sequence,
326 configuredSequence + 1,
327 );
328 assert.equal(metricsReadCount, metricsAfterStopping);
329
330 yield* Ref.set(systemIdleState, "locked");
331 yield* TestClock.adjust(Duration.seconds(7));
332 const lockedSequence = (yield* publisher.latest).pipe(Option.getOrThrow).sequence;
333 assert.equal((yield* publisher.latest).pipe(Option.getOrThrow).power.locked, "true");
334 yield* TestClock.adjust(Duration.millis(10_999));
335 assert.equal((yield* publisher.latest).pipe(Option.getOrThrow).sequence, lockedSequence);
336 yield* TestClock.adjust(Duration.millis(1));
337 assert.equal(
338 (yield* publisher.latest).pipe(Option.getOrThrow).sequence,
339 lockedSequence + 1,
340 );
341
342 yield* Ref.set(systemIdleState, "active");
343 yield* TestClock.adjust(Duration.seconds(11));
344 const unlockedSnapshot = (yield* publisher.latest).pipe(Option.getOrThrow);
345 assert.equal(unlockedSnapshot.power.locked, "false");
346 assert.equal(unlockedSnapshot.power.idle, "false");
347
348 yield* TestClock.adjust(Duration.millis(6_999));
349 assert.equal(
350 (yield* publisher.latest).pipe(Option.getOrThrow).sequence,
351 unlockedSnapshot.sequence,
352 );
353 yield* TestClock.adjust(Duration.millis(1));
354 assert.equal(
355 (yield* publisher.latest).pipe(Option.getOrThrow).sequence,
356 unlockedSnapshot.sequence + 1,
357 );
358
359 const pollStarted = yield* Deferred.make<void>();
360 const releasePoll = yield* Deferred.make<void>();
361 beforeSystemIdleState = Deferred.succeed(pollStarted, undefined).pipe(
362 Effect.andThen(Deferred.await(releasePoll)),
363 );
364 const concurrentEventSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(
365 Effect.forkChild,
366 );
367 yield* Effect.yieldNow;
368 yield* publisher.handleControl({
369 version: 1,
370 type: "setDiagnosticsDemand",
371 enabled: true,
372 });
373 yield* Deferred.await(pollStarted);
374 simpleListeners.get("lock-screen")?.();
375 thermalListener?.("critical");
376 yield* Effect.yieldNow;
377 yield* Effect.yieldNow;
378 yield* Deferred.succeed(releasePoll, undefined);
379
380 const concurrentEventSnapshot = Option.getOrThrow(
381 yield* Fiber.join(concurrentEventSnapshotFiber),
382 );
383 assert.equal(concurrentEventSnapshot.power.locked, "true");
384 assert.equal(concurrentEventSnapshot.power.thermalState, "critical");
385 }).pipe(Effect.provide(layer));
386 }),
387 );
388});
apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts created+379
......@@ -0,0 +1,379 @@
1import {
2 DesktopHostTelemetryMessage,
3 type DesktopHostTelemetrySnapshot,
4 type DesktopTelemetryControlMessage,
5 type HostPowerSnapshot,
6} from "@t3tools/contracts";
7import * as Cause from "effect/Cause";
8import * as Context from "effect/Context";
9import * as DateTime from "effect/DateTime";
10import * as Duration from "effect/Duration";
11import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";
14import * as PubSub from "effect/PubSub";
15import * as Queue from "effect/Queue";
16import * as Ref from "effect/Ref";
17import * as Schema from "effect/Schema";
18import * as Stream from "effect/Stream";
19
20import * as ElectronApp from "../electron/ElectronApp.ts";
21import * as ElectronPowerMonitor from "../electron/ElectronPowerMonitor.ts";
22
23const LIVE_SAMPLE_INTERVAL = Duration.seconds(1);
24const BATTERY_SAMPLE_INTERVAL = Duration.seconds(5);
25const CONSTRAINED_SAMPLE_INTERVAL = Duration.seconds(15);
26const DEFAULT_HOST_POWER_ACTIVE_INTERVAL = Duration.seconds(30);
27const DEFAULT_HOST_POWER_IDLE_INTERVAL = Duration.minutes(2);
28const IDLE_THRESHOLD_SECONDS = 60;
29const encodeMessage = Schema.encodeSync(Schema.fromJsonString(DesktopHostTelemetryMessage));
30const textEncoder = new TextEncoder();
31
32type PowerEvent =
33 | { readonly type: "locked"; readonly value: boolean }
34 | { readonly type: "suspended"; readonly value: boolean }
35 | { readonly type: "onBattery"; readonly value: boolean }
36 | { readonly type: "thermal"; readonly value: HostPowerSnapshot["thermalState"] }
37 | { readonly type: "speedLimit"; readonly value: number };
38
39interface PowerState {
40 readonly idle: HostPowerSnapshot["idle"];
41 readonly locked: HostPowerSnapshot["locked"];
42 readonly lockedEventPending: boolean;
43 readonly suspended: boolean;
44 readonly suspendedEventPending: boolean;
45 readonly onBattery: HostPowerSnapshot["onBattery"];
46 readonly onBatteryEventPending: boolean;
47 readonly thermalState: HostPowerSnapshot["thermalState"];
48 readonly speedLimitPercent: Option.Option<number>;
49}
50
51interface HostPowerIntervals {
52 readonly active: Duration.Duration;
53 readonly idle: Duration.Duration;
54}
55
56export class DesktopTelemetryPublisher extends Context.Service<
57 DesktopTelemetryPublisher,
58 {
59 readonly latest: Effect.Effect<Option.Option<DesktopHostTelemetrySnapshot>>;
60 readonly changes: Stream.Stream<DesktopHostTelemetrySnapshot>;
61 readonly encoded: Stream.Stream<Uint8Array>;
62 readonly handleControl: (message: DesktopTelemetryControlMessage) => Effect.Effect<void>;
63 readonly handleControlForSource: (
64 sourceId: string,
65 message: DesktopTelemetryControlMessage,
66 ) => Effect.Effect<void>;
67 readonly removeControlSource: (sourceId: string) => Effect.Effect<void>;
68 }
69>()("@t3tools/desktop/telemetry/DesktopTelemetryPublisher") {}
70
71function booleanState(value: boolean): HostPowerSnapshot["onBattery"] {
72 return value ? "true" : "false";
73}
74
75function idleState(value: ElectronPowerMonitor.ElectronIdleState): HostPowerSnapshot["idle"] {
76 switch (value) {
77 case "active":
78 return "false";
79 case "idle":
80 case "locked":
81 return "true";
82 case "unknown":
83 return "unknown";
84 }
85}
86
87function updatePowerState(state: PowerState, event: PowerEvent): PowerState {
88 switch (event.type) {
89 case "locked":
90 return {
91 ...state,
92 locked: booleanState(event.value),
93 lockedEventPending: true,
94 };
95 case "suspended":
96 return {
97 ...state,
98 suspended: event.value,
99 suspendedEventPending: event.value,
100 };
101 case "onBattery":
102 return {
103 ...state,
104 onBattery: booleanState(event.value),
105 onBatteryEventPending: true,
106 };
107 case "thermal":
108 return { ...state, thermalState: event.value };
109 case "speedLimit":
110 return { ...state, speedLimitPercent: Option.some(event.value) };
111 }
112}
113
114function sampleInterval(
115 power: PowerState,
116 diagnosticsDemand: boolean,
117 hostPowerIntervals: HostPowerIntervals,
118): Duration.Duration {
119 if (!diagnosticsDemand) {
120 return power.suspended || power.locked === "true" || power.idle === "true"
121 ? hostPowerIntervals.idle
122 : hostPowerIntervals.active;
123 }
124 if (
125 power.suspended ||
126 power.locked === "true" ||
127 power.thermalState === "serious" ||
128 power.thermalState === "critical"
129 ) {
130 return CONSTRAINED_SAMPLE_INTERVAL;
131 }
132 if (power.onBattery === "true") return BATTERY_SAMPLE_INTERVAL;
133 return LIVE_SAMPLE_INTERVAL;
134}
135
136export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {
137 const electronApp = yield* ElectronApp.ElectronApp;
138 const powerMonitor = yield* ElectronPowerMonitor.ElectronPowerMonitor;
139 yield* electronApp.whenReady;
140
141 const initialPowerState: PowerState = {
142 idle: "unknown",
143 locked: "unknown",
144 lockedEventPending: false,
145 suspended: false,
146 suspendedEventPending: false,
147 onBattery: booleanState(yield* powerMonitor.isOnBatteryPower),
148 onBatteryEventPending: false,
149 thermalState: yield* powerMonitor.getCurrentThermalState,
150 speedLimitPercent: Option.none(),
151 };
152 const powerState = yield* Ref.make(initialPowerState);
153 const hostPowerIntervals = yield* Ref.make<HostPowerIntervals>({
154 active: DEFAULT_HOST_POWER_ACTIVE_INTERVAL,
155 idle: DEFAULT_HOST_POWER_IDLE_INTERVAL,
156 });
157 const powerEvents = yield* Queue.unbounded<PowerEvent>();
158 const sampleTriggers = yield* Queue.sliding<void>(1);
159 const diagnosticsDemandSources = yield* Ref.make<ReadonlySet<string>>(new Set());
160 const latest = yield* Ref.make(Option.none<DesktopHostTelemetrySnapshot>());
161 const changes = yield* PubSub.sliding<DesktopHostTelemetrySnapshot>(8);
162 const sequence = yield* Ref.make(0);
163
164 const offer = (event: PowerEvent): void => {
165 Queue.offerUnsafe(powerEvents, event);
166 };
167 yield* Effect.all(
168 [
169 powerMonitor.onSimpleEvent("lock-screen", () => offer({ type: "locked", value: true })),
170 powerMonitor.onSimpleEvent("unlock-screen", () => offer({ type: "locked", value: false })),
171 powerMonitor.onSimpleEvent("suspend", () => offer({ type: "suspended", value: true })),
172 powerMonitor.onSimpleEvent("resume", () => offer({ type: "suspended", value: false })),
173 powerMonitor.onSimpleEvent("on-battery", () => offer({ type: "onBattery", value: true })),
174 powerMonitor.onSimpleEvent("on-ac", () => offer({ type: "onBattery", value: false })),
175 powerMonitor.onThermalStateChange((value) => offer({ type: "thermal", value })),
176 powerMonitor.onSpeedLimitChange((value) => offer({ type: "speedLimit", value })),
177 ],
178 { concurrency: "unbounded" },
179 );
180 yield* Effect.forever(
181 Queue.take(powerEvents).pipe(
182 Effect.flatMap((event) => Ref.update(powerState, (state) => updatePowerState(state, event))),
183 Effect.andThen(Queue.offer(sampleTriggers, undefined)),
184 ),
185 ).pipe(Effect.forkScoped);
186
187 const sampleOnce = (allowSuspendRecovery: boolean) =>
188 Effect.gen(function* () {
189 const sampledAt = yield* DateTime.now;
190 const sampledAtUnixMs = DateTime.toEpochMillis(sampledAt);
191 const demand = (yield* Ref.get(diagnosticsDemandSources)).size > 0;
192 const [currentPower, idleSeconds, systemIdleState, onBattery, metrics] = yield* Effect.all(
193 [
194 Ref.get(powerState),
195 powerMonitor.getSystemIdleTime,
196 powerMonitor.getSystemIdleState(IDLE_THRESHOLD_SECONDS),
197 powerMonitor.isOnBatteryPower,
198 demand ? electronApp.getAppMetrics : Effect.succeed([]),
199 ],
200 { concurrency: "unbounded" },
201 );
202 const polledLocked =
203 systemIdleState === "unknown"
204 ? currentPower.locked
205 : booleanState(systemIdleState === "locked");
206 const polledOnBattery = booleanState(onBattery);
207 const observedPower = yield* Ref.modify(powerState, (latestPower) => {
208 const preserveEventLocked =
209 latestPower.lockedEventPending ||
210 latestPower.locked !== currentPower.locked ||
211 latestPower.lockedEventPending !== currentPower.lockedEventPending;
212 const preserveEventOnBattery =
213 latestPower.onBatteryEventPending ||
214 latestPower.onBattery !== currentPower.onBattery ||
215 latestPower.onBatteryEventPending !== currentPower.onBatteryEventPending;
216 const preserveEventSuspended =
217 latestPower.suspendedEventPending ||
218 latestPower.suspended !== currentPower.suspended ||
219 latestPower.suspendedEventPending !== currentPower.suspendedEventPending;
220 const next: PowerState = {
221 ...latestPower,
222 idle: idleState(systemIdleState),
223 locked: preserveEventLocked ? latestPower.locked : polledLocked,
224 lockedEventPending:
225 latestPower.lockedEventPending &&
226 (systemIdleState === "unknown" || polledLocked !== latestPower.locked),
227 suspended:
228 allowSuspendRecovery && !preserveEventSuspended ? false : latestPower.suspended,
229 suspendedEventPending: false,
230 onBattery: preserveEventOnBattery ? latestPower.onBattery : polledOnBattery,
231 onBatteryEventPending:
232 latestPower.onBatteryEventPending && polledOnBattery !== latestPower.onBattery,
233 };
234 return [next, next] as const;
235 });
236 const nextSequence = yield* Ref.modify(sequence, (current) => [current + 1, current + 1]);
237 const snapshot: DesktopHostTelemetrySnapshot = {
238 version: 1,
239 type: "desktopTelemetry",
240 sequence: nextSequence,
241 sampledAtUnixMs,
242 electronPid: process.pid,
243 power: {
244 source: "electron-main",
245 idle: observedPower.idle,
246 idleSeconds,
247 locked: observedPower.locked,
248 suspended: observedPower.suspended,
249 onBattery: observedPower.onBattery,
250 lowPowerMode: "unknown",
251 thermalState: observedPower.thermalState,
252 stale: false,
253 updatedAt: sampledAt,
254 },
255 speedLimitPercent: observedPower.speedLimitPercent,
256 electronProcesses: metrics.map((metric) => ({
257 pid: metric.pid,
258 creationTimeMs: Math.max(0, Math.round(metric.creationTime)),
259 type: metric.type,
260 ...(metric.name === undefined ? {} : { name: metric.name }),
261 ...(metric.serviceName === undefined ? {} : { serviceName: metric.serviceName }),
262 cpuPercent: metric.cpu.percentCPUUsage,
263 ...(metric.cpu.cumulativeCPUUsage === undefined
264 ? {}
265 : { cumulativeCpuSeconds: metric.cpu.cumulativeCPUUsage }),
266 idleWakeupsPerSecond: metric.cpu.idleWakeupsPerSecond,
267 workingSetBytes: Math.max(0, Math.round(metric.memory.workingSetSize * 1024)),
268 peakWorkingSetBytes: Math.max(0, Math.round(metric.memory.peakWorkingSetSize * 1024)),
269 })),
270 };
271
272 yield* Ref.set(latest, Option.some(snapshot));
273 yield* PubSub.publish(changes, snapshot);
274 }).pipe(
275 Effect.catchCause((cause) =>
276 Cause.hasInterrupts(cause)
277 ? Effect.failCause(cause)
278 : Effect.logWarning("Failed to sample Electron telemetry", {
279 cause: String(cause),
280 }),
281 ),
282 );
283
284 yield* Effect.gen(function* () {
285 yield* sampleOnce(false);
286 while (true) {
287 const [currentPower, demand, intervals] = yield* Effect.all([
288 Ref.get(powerState),
289 Ref.get(diagnosticsDemandSources).pipe(Effect.map((sources) => sources.size > 0)),
290 Ref.get(hostPowerIntervals),
291 ]);
292 const allowSuspendRecovery = yield* Effect.raceFirst(
293 Queue.take(sampleTriggers).pipe(Effect.as(false)),
294 Effect.sleep(sampleInterval(currentPower, demand, intervals)).pipe(Effect.as(true)),
295 );
296 yield* sampleOnce(allowSuspendRecovery);
297 }
298 }).pipe(Effect.forkScoped);
299
300 const handleControlForSource: DesktopTelemetryPublisher["Service"]["handleControlForSource"] = (
301 sourceId,
302 message,
303 ) => {
304 switch (message.type) {
305 case "setDiagnosticsDemand":
306 return Ref.modify(diagnosticsDemandSources, (sources) => {
307 const previous = sources.size > 0;
308 const next = new Set(sources);
309 if (message.enabled) {
310 next.add(sourceId);
311 } else {
312 next.delete(sourceId);
313 }
314 return [[previous, next.size > 0] as const, next] as const;
315 }).pipe(
316 Effect.flatMap(([previous, enabled]) =>
317 previous === enabled
318 ? Effect.void
319 : Queue.offer(sampleTriggers, undefined).pipe(Effect.asVoid),
320 ),
321 );
322 case "setHostPowerIntervals":
323 return Ref.set(hostPowerIntervals, {
324 active: Duration.millis(message.activeIntervalMs),
325 idle: Duration.millis(message.idleIntervalMs),
326 }).pipe(Effect.andThen(Queue.offer(sampleTriggers, undefined)), Effect.asVoid);
327 }
328 };
329 const removeControlSource: DesktopTelemetryPublisher["Service"]["removeControlSource"] = (
330 sourceId,
331 ) =>
332 Ref.modify(diagnosticsDemandSources, (sources) => {
333 const previous = sources.size > 0;
334 const next = new Set(sources);
335 next.delete(sourceId);
336 return [[previous, next.size > 0] as const, next] as const;
337 }).pipe(
338 Effect.flatMap(([previous, enabled]) =>
339 previous === enabled
340 ? Effect.void
341 : Queue.offer(sampleTriggers, undefined).pipe(Effect.asVoid),
342 ),
343 );
344 const handleControl: DesktopTelemetryPublisher["Service"]["handleControl"] = (message) =>
345 handleControlForSource("legacy", message);
346
347 const snapshots = Stream.unwrap(
348 Effect.gen(function* () {
349 const subscription = yield* PubSub.subscribe(changes);
350 const initial = yield* Ref.get(latest);
351 return Stream.concat(
352 Option.match(initial, {
353 onNone: () => Stream.empty,
354 onSome: Stream.make,
355 }),
356 Stream.fromSubscription(subscription),
357 );
358 }),
359 );
360 const encoded = Stream.concat(
361 Stream.make({
362 version: 1,
363 type: "desktopTelemetryHello",
364 electronPid: process.pid,
365 } as const),
366 snapshots,
367 ).pipe(Stream.map((message) => textEncoder.encode(`${encodeMessage(message)}\n`)));
368
369 return DesktopTelemetryPublisher.of({
370 latest: Ref.get(latest),
371 changes: Stream.fromPubSub(changes),
372 encoded,
373 handleControl,
374 handleControlForSource,
375 removeControlSource,
376 });
377});
378
379export const layer = Layer.effect(DesktopTelemetryPublisher, make());
apps/desktop/src/window/DesktopApplicationMenu.test.ts+1
......@@ -40,6 +40,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
4040 setAboutPanelOptions: () => Effect.void,
4141 setAppUserModelId: () => Effect.void,
4242 requestSingleInstanceLock: Effect.succeed(true),
43 getAppMetrics: Effect.succeed([]),
4344 isDefaultProtocolClient: () => Effect.succeed(false),
4445 setAsDefaultProtocolClient: () => Effect.succeed(true),
4546 setDesktopName: () => Effect.void,
apps/mobile/src/connection/background-activity-scopes.ts created+92
......@@ -0,0 +1,92 @@
1import type { EnvironmentRpcSubscriptionObservation } from "@t3tools/client-runtime/rpc";
2import { type BackgroundScope, type EnvironmentId, WS_METHODS } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4
5interface RetainedScope {
6 readonly environmentId: EnvironmentId;
7 readonly scope: BackgroundScope;
8 refCount: number;
9}
10
11const retainedScopes = new Map<string, RetainedScope>();
12const listeners = new Set<() => void>();
13
14function notify(): void {
15 for (const listener of listeners) {
16 try {
17 listener();
18 } catch {
19 // A failing observer must not corrupt retained-scope lifetime.
20 }
21 }
22}
23
24function stableScopeKey(environmentId: EnvironmentId, scope: BackgroundScope): string {
25 switch (scope.type) {
26 case "server-config":
27 case "diagnostics":
28 return JSON.stringify([environmentId, scope.type]);
29 case "provider-status":
30 return JSON.stringify([environmentId, scope.type, scope.instanceId ?? null]);
31 case "vcs-status":
32 case "git-refs":
33 return JSON.stringify([environmentId, scope.type, scope.cwd]);
34 case "thread":
35 return JSON.stringify([environmentId, scope.type, scope.threadId]);
36 }
37}
38
39function scopeForSubscription(
40 observation: EnvironmentRpcSubscriptionObservation,
41): BackgroundScope | null {
42 if (observation.method === WS_METHODS.subscribeResourceTelemetry) {
43 return { type: "diagnostics" };
44 }
45 if (observation.method !== WS_METHODS.subscribeVcsStatus) {
46 return null;
47 }
48 const input = observation.input as { readonly cwd?: unknown };
49 return typeof input.cwd === "string" ? { type: "vcs-status", cwd: input.cwd } : null;
50}
51
52export function retainedMobileBackgroundScopes(
53 environmentId: EnvironmentId,
54): ReadonlyArray<BackgroundScope> {
55 return Array.from(retainedScopes.values(), (entry) =>
56 entry.environmentId === environmentId ? entry.scope : null,
57 ).filter((scope): scope is BackgroundScope => scope !== null);
58}
59
60export function observeMobileBackgroundActivitySubscription(
61 observation: EnvironmentRpcSubscriptionObservation,
62): Effect.Effect<Effect.Effect<void>> {
63 const scope = scopeForSubscription(observation);
64 if (scope === null) return Effect.succeed(Effect.void);
65 return Effect.sync(() => {
66 const environmentId = observation.environmentId as EnvironmentId;
67 const key = stableScopeKey(environmentId, scope);
68 const current = retainedScopes.get(key);
69 if (current) {
70 current.refCount += 1;
71 } else {
72 retainedScopes.set(key, { environmentId, scope, refCount: 1 });
73 notify();
74 }
75 return Effect.sync(() => {
76 const retained = retainedScopes.get(key);
77 if (!retained) return;
78 retained.refCount -= 1;
79 if (retained.refCount <= 0) {
80 retainedScopes.delete(key);
81 notify();
82 }
83 });
84 });
85}
86
87export function onRetainedMobileBackgroundScopesChange(listener: () => void): () => void {
88 listeners.add(listener);
89 return () => {
90 listeners.delete(listener);
91 };
92}
apps/mobile/src/connection/background-activity.test.ts created+77
......@@ -0,0 +1,77 @@
1import { EnvironmentId, WS_METHODS } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4
5import {
6 onRetainedMobileBackgroundScopesChange,
7 observeMobileBackgroundActivitySubscription,
8 retainedMobileBackgroundScopes,
9} from "./background-activity-scopes";
10
11describe("mobile background activity", () => {
12 it.effect("retains VCS demand only while the mobile subscription is active", () =>
13 Effect.gen(function* () {
14 const environmentId = EnvironmentId.make("mobile-environment");
15 const release = yield* observeMobileBackgroundActivitySubscription({
16 environmentId,
17 method: WS_METHODS.subscribeVcsStatus,
18 input: { cwd: "/workspace" },
19 });
20
21 expect(retainedMobileBackgroundScopes(environmentId)).toEqual([
22 { type: "vcs-status", cwd: "/workspace" },
23 ]);
24
25 yield* release;
26 expect(retainedMobileBackgroundScopes(environmentId)).toEqual([]);
27 }),
28 );
29
30 it.effect("keeps delimiter-containing environment and scope values distinct", () =>
31 Effect.gen(function* () {
32 const firstEnvironmentId = EnvironmentId.make("a");
33 const secondEnvironmentId = EnvironmentId.make("a:vcs-status:b");
34 const releaseFirst = yield* observeMobileBackgroundActivitySubscription({
35 environmentId: firstEnvironmentId,
36 method: WS_METHODS.subscribeVcsStatus,
37 input: { cwd: "b:vcs-status:c" },
38 });
39 const releaseSecond = yield* observeMobileBackgroundActivitySubscription({
40 environmentId: secondEnvironmentId,
41 method: WS_METHODS.subscribeVcsStatus,
42 input: { cwd: "c" },
43 });
44
45 expect(retainedMobileBackgroundScopes(firstEnvironmentId)).toEqual([
46 { type: "vcs-status", cwd: "b:vcs-status:c" },
47 ]);
48 expect(retainedMobileBackgroundScopes(secondEnvironmentId)).toEqual([
49 { type: "vcs-status", cwd: "c" },
50 ]);
51
52 yield* Effect.all([releaseFirst, releaseSecond]);
53 }),
54 );
55
56 it.effect("returns a release handle when a retained-scope listener throws", () =>
57 Effect.gen(function* () {
58 const environmentId = EnvironmentId.make("throwing-listener-environment");
59 const removeListener = onRetainedMobileBackgroundScopesChange(() => {
60 throw new Error("listener failed");
61 });
62
63 const release = yield* observeMobileBackgroundActivitySubscription({
64 environmentId,
65 method: WS_METHODS.subscribeVcsStatus,
66 input: { cwd: "/workspace" },
67 });
68 expect(retainedMobileBackgroundScopes(environmentId)).toEqual([
69 { type: "vcs-status", cwd: "/workspace" },
70 ]);
71
72 yield* release;
73 expect(retainedMobileBackgroundScopes(environmentId)).toEqual([]);
74 removeListener();
75 }),
76 );
77});
apps/mobile/src/connection/background-activity.ts created+116
......@@ -0,0 +1,116 @@
1import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
2import { EnvironmentRpcSubscriptionObserver, request } from "@t3tools/client-runtime/rpc";
3import {
4 type BackgroundScope,
5 type ClientActivityReportInput,
6 type EnvironmentId,
7 WS_METHODS,
8} from "@t3tools/contracts";
9import * as Clock from "effect/Clock";
10import * as DateTime from "effect/DateTime";
11import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";
13import * as Queue from "effect/Queue";
14import * as Schedule from "effect/Schedule";
15import * as Stream from "effect/Stream";
16import * as SubscriptionRef from "effect/SubscriptionRef";
17import { AppState, type AppStateStatus } from "react-native";
18
19import * as MobileStorage from "../persistence/mobile-storage";
20import {
21 observeMobileBackgroundActivitySubscription,
22 onRetainedMobileBackgroundScopesChange,
23 retainedMobileBackgroundScopes,
24} from "./background-activity-scopes";
25
26const REPORT_INTERVAL_MS = 25_000;
27const LEASE_TTL_MS = 45_000;
28const BASELINE_SCOPES: ReadonlyArray<BackgroundScope> = [{ type: "provider-status" }];
29
30function normalizeAppState(
31 state: AppStateStatus,
32): NonNullable<ClientActivityReportInput["appState"]> {
33 if (state === "active" || state === "inactive" || state === "background") return state;
34 return "unknown";
35}
36
37export const mobileBackgroundActivityObserverLayer = Layer.succeed(
38 EnvironmentRpcSubscriptionObserver,
39 EnvironmentRpcSubscriptionObserver.of({
40 observe: observeMobileBackgroundActivitySubscription,
41 }),
42);
43
44export const mobileBackgroundActivityReporterLayer = Layer.effectDiscard(
45 Effect.gen(function* () {
46 const registry = yield* EnvironmentRegistry;
47 const storage = yield* MobileStorage.MobileStorage;
48 const clientId = yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe(
49 Effect.map((deviceId) => `mobile-${deviceId}`),
50 Effect.orElseSucceed(() => "ephemeral-mobile-client"),
51 );
52 const reportRequests = yield* Queue.sliding<void>(1);
53 const requestReport = () => Queue.offerUnsafe(reportRequests, undefined);
54 let appState = AppState.currentState;
55
56 const report = Effect.gen(function* () {
57 const observedAtMs = yield* Clock.currentTimeMillis;
58 const active = appState === "active";
59 const entries = yield* SubscriptionRef.get(registry.entries);
60 yield* Effect.forEach(
61 entries.keys(),
62 (environmentId) =>
63 registry
64 .run(
65 environmentId,
66 request(WS_METHODS.serverReportClientActivity, {
67 environmentId: environmentId as EnvironmentId,
68 clientId,
69 clientKind: "mobile",
70 visible: active,
71 focused: active,
72 recentlyInteracted: active,
73 appState: normalizeAppState(appState),
74 scopes: [
75 ...BASELINE_SCOPES,
76 ...retainedMobileBackgroundScopes(environmentId as EnvironmentId),
77 ],
78 ttlMs: LEASE_TTL_MS,
79 observedAt: DateTime.makeUnsafe(observedAtMs),
80 }),
81 )
82 .pipe(Effect.ignore),
83 { concurrency: "unbounded", discard: true },
84 );
85 }).pipe(Effect.withSpan("mobile.backgroundActivity.report"));
86
87 yield* Effect.acquireRelease(
88 Effect.sync(() => {
89 const removeScopeListener = onRetainedMobileBackgroundScopesChange(requestReport);
90 const subscription = AppState.addEventListener("change", (nextState) => {
91 appState = nextState;
92 requestReport();
93 });
94 return { removeScopeListener, subscription };
95 }),
96 ({ removeScopeListener, subscription }) =>
97 Effect.sync(() => {
98 removeScopeListener();
99 subscription.remove();
100 }),
101 );
102 yield* SubscriptionRef.changes(registry.entries).pipe(
103 Stream.runForEach(() => Effect.sync(requestReport)),
104 Effect.forkScoped,
105 );
106 yield* Stream.fromQueue(reportRequests).pipe(
107 Stream.debounce("250 millis"),
108 Stream.runForEach(() => report),
109 Effect.forkScoped,
110 );
111 yield* Effect.sync(requestReport).pipe(
112 Effect.repeat(Schedule.spaced(`${REPORT_INTERVAL_MS} millis`)),
113 Effect.forkScoped,
114 );
115 }),
116);
apps/mobile/src/connection/runtime.ts+19-3
......@@ -5,6 +5,10 @@ import * as Layer from "effect/Layer";
55import { Atom } from "effect/unstable/reactivity";
66
77import { runtimeContextLayer } from "../lib/runtime";
8import {
9 mobileBackgroundActivityObserverLayer,
10 mobileBackgroundActivityReporterLayer,
11} from "./background-activity";
812import { connectionPlatformLayer } from "./platform";
913
1014const providedConnectionPlatformLayer = connectionPlatformLayer.pipe(
......@@ -17,10 +21,22 @@ type ConnectionLayerSource =
1721 | typeof Connection.layer
1822 | typeof snapshotLoaderLayer
1923 | typeof runtimeContextLayer
20 | typeof connectionPlatformLayer;
24 | typeof connectionPlatformLayer
25 | typeof mobileBackgroundActivityObserverLayer
26 | typeof mobileBackgroundActivityReporterLayer;
2127
22const connectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe(
23 Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)),
28const providedClientConnectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe(
29 Layer.provideMerge(
30 Layer.mergeAll(
31 runtimeContextLayer,
32 providedConnectionPlatformLayer,
33 mobileBackgroundActivityObserverLayer,
34 ),
35 ),
36);
37
38const connectionLayer = mobileBackgroundActivityReporterLayer.pipe(
39 Layer.provideMerge(providedClientConnectionLayer),
2440);
2541
2642export const connectionAtomRuntime: Atom.AtomRuntime<
apps/server/src/auth/RpcAuthorization.test.ts created+47
......@@ -0,0 +1,47 @@
1import {
2 AuthOrchestrationOperateScope,
3 AuthOrchestrationReadScope,
4 AuthRelayReadScope,
5 AuthRelayWriteScope,
6 WS_METHODS,
7 WsRpcGroup,
8} from "@t3tools/contracts";
9import { describe, expect, it } from "@effect/vitest";
10
11import { RPC_REQUIRED_SCOPES, requiredScopeForRpcMethod } from "./RpcAuthorization.ts";
12
13describe("RPC authorization scopes", () => {
14 it("declares exactly one scope for every RPC in the server group", () => {
15 expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys()));
16 });
17
18 it("authorizes background policy reporting and observation deliberately", () => {
19 expect(requiredScopeForRpcMethod(WS_METHODS.serverReportClientActivity)).toBe(
20 AuthOrchestrationReadScope,
21 );
22 expect(requiredScopeForRpcMethod(WS_METHODS.serverReportHostPowerState)).toBe(
23 AuthOrchestrationOperateScope,
24 );
25 expect(requiredScopeForRpcMethod(WS_METHODS.serverGetBackgroundPolicy)).toBe(
26 AuthOrchestrationReadScope,
27 );
28 expect(requiredScopeForRpcMethod(WS_METHODS.subscribeBackgroundPolicy)).toBe(
29 AuthOrchestrationReadScope,
30 );
31 });
32
33 it("allows relay status reads without granting relay installation access", () => {
34 expect(requiredScopeForRpcMethod(WS_METHODS.cloudGetRelayClientStatus)).toBe(
35 AuthRelayReadScope,
36 );
37 expect(requiredScopeForRpcMethod(WS_METHODS.cloudInstallRelayClient)).toBe(AuthRelayWriteScope);
38 });
39
40 it("rejects unknown RPC method names", () => {
41 for (const method of ["server.notRegistered", "toString", "constructor"]) {
42 expect(() => requiredScopeForRpcMethod(method)).toThrow(
43 `RPC method ${method} has no declared authorization scope.`,
44 );
45 }
46 });
47});
apps/server/src/auth/RpcAuthorization.ts created+111
......@@ -0,0 +1,111 @@
1import {
2 AuthAccessReadScope,
3 AuthOrchestrationOperateScope,
4 AuthOrchestrationReadScope,
5 AuthRelayReadScope,
6 AuthRelayWriteScope,
7 AuthReviewWriteScope,
8 AuthTerminalOperateScope,
9 ORCHESTRATION_WS_METHODS,
10 type AuthEnvironmentScope,
11 WS_METHODS,
12 WsRpcGroup,
13} from "@t3tools/contracts";
14import type * as RpcGroup from "effect/unstable/rpc/RpcGroup";
15
16type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];
17
18/**
19 * Keep authorization coverage coupled to the RPC group itself. Adding an RPC to
20 * `WsRpcGroup` without choosing a scope is a type error instead of a production
21 * runtime failure.
22 */
23export const RPC_REQUIRED_SCOPES = {
24 [ORCHESTRATION_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope,
25 [ORCHESTRATION_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope,
26 [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope,
27 [ORCHESTRATION_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope,
28 [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope,
29 [ORCHESTRATION_WS_METHODS.subscribeThread]: AuthOrchestrationReadScope,
30 [WS_METHODS.serverProbe]: AuthOrchestrationReadScope,
31 [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope,
32 [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope,
33 [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope,
34 [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope,
35 [WS_METHODS.serverUpsertKeybinding]: AuthOrchestrationOperateScope,
36 [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope,
37 [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope,
38 [WS_METHODS.serverUpdateSettings]: AuthOrchestrationOperateScope,
39 [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope,
40 [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope,
41 [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope,
42 [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope,
43 [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope,
44 [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope,
45 [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope,
46 [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope,
47 [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope,
48 [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope,
49 [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope,
50 [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope,
51 [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope,
52 [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope,
53 [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope,
54 [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope,
55 [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope,
56 [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope,
57 [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope,
58 [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,
59 [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope,
60 [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope,
61 [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope,
62 [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope,
63 [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope,
64 [WS_METHODS.vcsPull]: AuthOrchestrationOperateScope,
65 [WS_METHODS.gitRunStackedAction]: AuthOrchestrationOperateScope,
66 [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationOperateScope,
67 [WS_METHODS.gitPreparePullRequestThread]: AuthOrchestrationOperateScope,
68 [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope,
69 [WS_METHODS.vcsCreateWorktree]: AuthOrchestrationOperateScope,
70 [WS_METHODS.vcsRemoveWorktree]: AuthOrchestrationOperateScope,
71 [WS_METHODS.vcsCreateRef]: AuthOrchestrationOperateScope,
72 [WS_METHODS.vcsSwitchRef]: AuthOrchestrationOperateScope,
73 [WS_METHODS.vcsInit]: AuthOrchestrationOperateScope,
74 [WS_METHODS.reviewGetDiffPreview]: AuthReviewWriteScope,
75 [WS_METHODS.terminalOpen]: AuthTerminalOperateScope,
76 [WS_METHODS.terminalAttach]: AuthTerminalOperateScope,
77 [WS_METHODS.terminalWrite]: AuthTerminalOperateScope,
78 [WS_METHODS.terminalResize]: AuthTerminalOperateScope,
79 [WS_METHODS.terminalClear]: AuthTerminalOperateScope,
80 [WS_METHODS.terminalRestart]: AuthTerminalOperateScope,
81 [WS_METHODS.terminalClose]: AuthTerminalOperateScope,
82 [WS_METHODS.subscribeTerminalEvents]: AuthTerminalOperateScope,
83 [WS_METHODS.subscribeTerminalMetadata]: AuthTerminalOperateScope,
84 [WS_METHODS.previewOpen]: AuthOrchestrationOperateScope,
85 [WS_METHODS.previewNavigate]: AuthOrchestrationOperateScope,
86 [WS_METHODS.previewResize]: AuthOrchestrationOperateScope,
87 [WS_METHODS.previewRefresh]: AuthOrchestrationOperateScope,
88 [WS_METHODS.previewClose]: AuthOrchestrationOperateScope,
89 [WS_METHODS.previewList]: AuthOrchestrationReadScope,
90 [WS_METHODS.previewReportStatus]: AuthOrchestrationOperateScope,
91 [WS_METHODS.previewAutomationConnect]: AuthOrchestrationOperateScope,
92 [WS_METHODS.previewAutomationRespond]: AuthOrchestrationOperateScope,
93 [WS_METHODS.previewAutomationFocusHost]: AuthOrchestrationOperateScope,
94 [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,
95 [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,
96 [WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope,
97 [WS_METHODS.subscribeServerLifecycle]: AuthOrchestrationReadScope,
98 [WS_METHODS.subscribeAuthAccess]: AuthAccessReadScope,
99 [WS_METHODS.subscribeBackgroundPolicy]: AuthOrchestrationReadScope,
100} as const satisfies Readonly<Record<WsRpcMethod, AuthEnvironmentScope>>;
101
102export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope {
103 if (!Object.hasOwn(RPC_REQUIRED_SCOPES, method)) {
104 throw new Error(`RPC method ${method} has no declared authorization scope.`);
105 }
106 const requiredScope = RPC_REQUIRED_SCOPES[method as WsRpcMethod];
107 if (requiredScope === undefined) {
108 throw new Error(`RPC method ${method} has no declared authorization scope.`);
109 }
110 return requiredScope;
111}
apps/server/src/background/BackgroundPolicy.test.ts created+386
......@@ -0,0 +1,386 @@
1import { assert, describe, it } from "@effect/vitest";
2import {
3 AuthSessionId,
4 RpcClientId,
5 type HostPowerSnapshot,
6 type ClientActivityReportInput,
7} from "@t3tools/contracts";
8import * as DateTime from "effect/DateTime";
9import * as Deferred from "effect/Deferred";
10import * as Effect from "effect/Effect";
11import * as Fiber from "effect/Fiber";
12import * as Layer from "effect/Layer";
13import * as PubSub from "effect/PubSub";
14import * as Ref from "effect/Ref";
15import * as Stream from "effect/Stream";
16
17import { ServerSettingsService } from "../serverSettings.ts";
18import * as BackgroundPolicy from "./BackgroundPolicy.ts";
19import * as HostPowerMonitor from "./HostPowerMonitor.ts";
20
21const TEST_NOW = DateTime.makeUnsafe("2026-05-13T00:00:00.000Z");
22
23const nominalHostPower: HostPowerSnapshot = {
24 source: "unknown",
25 idle: "unknown",
26 idleSeconds: null,
27 locked: "unknown",
28 suspended: false,
29 onBattery: "unknown",
30 lowPowerMode: "unknown",
31 thermalState: "unknown",
32 stale: true,
33 updatedAt: TEST_NOW,
34};
35
36const constrainedHostPower: HostPowerSnapshot = {
37 ...nominalHostPower,
38 lowPowerMode: "true",
39 stale: false,
40};
41
42function makeReport(overrides: Partial<ClientActivityReportInput> = {}): ClientActivityReportInput {
43 return {
44 clientId: "client-1",
45 clientKind: "web",
46 visible: true,
47 focused: true,
48 recentlyInteracted: true,
49 scopes: [{ type: "vcs-status", cwd: "/repo" }],
50 ttlMs: 45_000,
51 observedAt: TEST_NOW,
52 ...overrides,
53 };
54}
55
56function makeLayer(
57 hostPower: HostPowerSnapshot,
58 settingsOverrides: Parameters<typeof ServerSettingsService.layerTest>[0] = {},
59) {
60 const hostLayer = Layer.effect(
61 HostPowerMonitor.HostPowerMonitor,
62 Effect.gen(function* () {
63 const changes = yield* PubSub.sliding<HostPowerSnapshot>(1);
64 let snapshot = hostPower;
65 return HostPowerMonitor.HostPowerMonitor.of({
66 snapshot: Effect.sync(() => snapshot),
67 report: (next) =>
68 Effect.sync(() => {
69 snapshot = next;
70 }).pipe(Effect.andThen(PubSub.publish(changes, next)), Effect.asVoid),
71 streamChanges: Stream.fromPubSub(changes),
72 });
73 }),
74 );
75 return BackgroundPolicy.layer.pipe(
76 Layer.provide(Layer.merge(hostLayer, ServerSettingsService.layerTest(settingsOverrides))),
77 );
78}
79
80describe("BackgroundPolicy", () => {
81 it.effect("records foreground scoped client demand", () =>
82 Effect.gen(function* () {
83 const policy = yield* BackgroundPolicy.BackgroundPolicy;
84 yield* policy.reportClientActivity(
85 AuthSessionId.make("session-1"),
86 RpcClientId.make(1),
87 makeReport(),
88 );
89
90 const snapshot = yield* policy.snapshot;
91 assert.equal(snapshot.activeForegroundLeaseCount, 1);
92 assert.deepStrictEqual(snapshot.activeScopeKeys, ["vcs-status:/repo"]);
93 assert.equal(snapshot.shouldRunOpportunisticWork, true);
94 assert.equal(yield* policy.hasDemand({ type: "vcs-status", cwd: "/repo" }), true);
95 assert.equal(yield* policy.hasDemand({ type: "vcs-status", cwd: "/other" }), false);
96 assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), true);
97 assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/other" }), false);
98 }).pipe(Effect.provide(makeLayer(nominalHostPower))),
99 );
100
101 it.effect("removes all leases for a disconnected websocket connection", () =>
102 Effect.gen(function* () {
103 const policy = yield* BackgroundPolicy.BackgroundPolicy;
104 yield* policy.reportClientActivity(
105 AuthSessionId.make("session-1"),
106 RpcClientId.make(1),
107 makeReport(),
108 );
109 yield* policy.removeRpcClient(AuthSessionId.make("session-1"), RpcClientId.make(1));
110
111 const snapshot = yield* policy.snapshot;
112 assert.equal(snapshot.activeForegroundLeaseCount, 0);
113 assert.deepStrictEqual(snapshot.activeScopeKeys, []);
114 assert.equal(snapshot.shouldRunOpportunisticWork, false);
115 }).pipe(Effect.provide(makeLayer(nominalHostPower))),
116 );
117
118 it.effect("keeps leases from another session when rpc client ids are reused", () =>
119 Effect.gen(function* () {
120 const policy = yield* BackgroundPolicy.BackgroundPolicy;
121 const rpcClientId = RpcClientId.make(1);
122 yield* policy.reportClientActivity(
123 AuthSessionId.make("session-1"),
124 rpcClientId,
125 makeReport({ clientId: "client-1" }),
126 );
127 yield* policy.reportClientActivity(
128 AuthSessionId.make("session-2"),
129 rpcClientId,
130 makeReport({ clientId: "client-2" }),
131 );
132
133 yield* policy.removeRpcClient(AuthSessionId.make("session-1"), rpcClientId);
134
135 const snapshot = yield* policy.snapshot;
136 assert.equal(snapshot.activeForegroundLeaseCount, 1);
137 assert.equal(snapshot.leases[0]?.sessionId, AuthSessionId.make("session-2"));
138 assert.equal(snapshot.leases[0]?.clientId, "client-2");
139 }).pipe(Effect.provide(makeLayer(nominalHostPower))),
140 );
141
142 it.effect("serializes lease mutation publications", () =>
143 Effect.gen(function* () {
144 const firstSnapshotStarted = yield* Deferred.make<void>();
145 const releaseFirstSnapshot = yield* Deferred.make<void>();
146 const snapshotReads = yield* Ref.make(0);
147 const hostLayer = Layer.succeed(
148 HostPowerMonitor.HostPowerMonitor,
149 HostPowerMonitor.HostPowerMonitor.of({
150 snapshot: Ref.updateAndGet(snapshotReads, (count) => count + 1).pipe(
151 Effect.flatMap((count) =>
152 count === 1
153 ? Deferred.succeed(firstSnapshotStarted, undefined).pipe(
154 Effect.andThen(Deferred.await(releaseFirstSnapshot)),
155 Effect.as(nominalHostPower),
156 )
157 : Effect.succeed(nominalHostPower),
158 ),
159 ),
160 report: () => Effect.void,
161 streamChanges: Stream.empty,
162 }),
163 );
164 const layer = BackgroundPolicy.layer.pipe(
165 Layer.provide(Layer.merge(hostLayer, ServerSettingsService.layerTest())),
166 );
167
168 yield* Effect.gen(function* () {
169 const policy = yield* BackgroundPolicy.BackgroundPolicy;
170 const updatesFiber = yield* policy.streamChanges.pipe(
171 Stream.take(2),
172 Stream.runCollect,
173 Effect.forkChild,
174 );
175 yield* Effect.yieldNow;
176 const reportFiber = yield* policy
177 .reportClientActivity(AuthSessionId.make("session-1"), RpcClientId.make(1), makeReport())
178 .pipe(Effect.forkChild);
179 yield* Deferred.await(firstSnapshotStarted);
180 const removeFiber = yield* policy
181 .removeRpcClient(AuthSessionId.make("session-1"), RpcClientId.make(1))
182 .pipe(Effect.forkChild);
183 yield* Effect.yieldNow;
184 yield* Deferred.succeed(releaseFirstSnapshot, undefined);
185 yield* Fiber.join(reportFiber);
186 yield* Fiber.join(removeFiber);
187 const updates = Array.from(yield* Fiber.join(updatesFiber));
188
189 assert.equal(updates.at(-1)?.activeForegroundLeaseCount, 0);
190 assert.deepStrictEqual(updates.at(-1)?.activeScopeKeys, []);
191 }).pipe(Effect.provide(layer));
192 }),
193 );
194
195 it.effect("bounds client-id churn for one websocket connection", () =>
196 Effect.gen(function* () {
197 const policy = yield* BackgroundPolicy.BackgroundPolicy;
198 const sessionId = AuthSessionId.make("session-1");
199 const rpcClientId = RpcClientId.make(1);
200 for (
201 let index = 0;
202 index <= BackgroundPolicy.MAX_CLIENT_ACTIVITY_LEASES_PER_RPC_CLIENT;
203 index += 1
204 ) {
205 yield* policy.reportClientActivity(
206 sessionId,
207 rpcClientId,
208 makeReport({ clientId: `client-${index}` }),
209 );
210 }
211
212 const snapshot = yield* policy.snapshot;
213 assert.equal(
214 snapshot.leases.length,
215 BackgroundPolicy.MAX_CLIENT_ACTIVITY_LEASES_PER_RPC_CLIENT,
216 );
217 assert.isTrue(
218 snapshot.leases.some(
219 (lease) =>
220 lease.clientId ===
221 `client-${BackgroundPolicy.MAX_CLIENT_ACTIVITY_LEASES_PER_RPC_CLIENT}`,
222 ),
223 );
224 }).pipe(Effect.provide(makeLayer(nominalHostPower))),
225 );
226
227 it.effect("host low power mode disables opportunistic work without dropping scoped demand", () =>
228 Effect.gen(function* () {
229 const policy = yield* BackgroundPolicy.BackgroundPolicy;
230 yield* policy.reportClientActivity(
231 AuthSessionId.make("session-1"),
232 RpcClientId.make(1),
233 makeReport(),
234 );
235
236 const snapshot = yield* policy.snapshot;
237 assert.equal(snapshot.activeForegroundLeaseCount, 1);
238 assert.deepStrictEqual(snapshot.activeScopeKeys, ["vcs-status:/repo"]);
239 assert.equal(snapshot.shouldRunOpportunisticWork, false);
240 assert.equal(yield* policy.hasDemand({ type: "vcs-status", cwd: "/repo" }), true);
241 assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), false);
242 }).pipe(Effect.provide(makeLayer(constrainedHostPower))),
243 );
244
245 it.effect("host suspension disables scoped and opportunistic work", () =>
246 Effect.gen(function* () {
247 const policy = yield* BackgroundPolicy.BackgroundPolicy;
248 yield* policy.reportClientActivity(
249 AuthSessionId.make("session-1"),
250 RpcClientId.make(1),
251 makeReport(),
252 );
253
254 const snapshot = yield* policy.snapshot;
255 assert.equal(snapshot.activeForegroundLeaseCount, 1);
256 assert.equal(snapshot.shouldRunOpportunisticWork, false);
257 assert.equal(yield* policy.hasDemand({ type: "vcs-status", cwd: "/repo" }), true);
258 assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), false);
259 }).pipe(
260 Effect.provide(
261 makeLayer({
262 ...nominalHostPower,
263 suspended: true,
264 stale: false,
265 }),
266 ),
267 ),
268 );
269
270 it.effect("keeps background demand visible while preventing scoped work", () =>
271 Effect.gen(function* () {
272 const policy = yield* BackgroundPolicy.BackgroundPolicy;
273 yield* policy.reportClientActivity(
274 AuthSessionId.make("session-1"),
275 RpcClientId.make(1),
276 makeReport({ focused: false, visible: false }),
277 );
278
279 const snapshot = yield* policy.snapshot;
280 assert.equal(snapshot.activeForegroundLeaseCount, 0);
281 assert.deepStrictEqual(snapshot.activeScopeKeys, ["vcs-status:/repo"]);
282 assert.equal(yield* policy.hasDemand({ type: "vcs-status", cwd: "/repo" }), true);
283 assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), false);
284 }).pipe(Effect.provide(makeLayer(nominalHostPower))),
285 );
286
287 it.effect("keeps scoped work active after a recently used visible window loses focus", () =>
288 Effect.gen(function* () {
289 const policy = yield* BackgroundPolicy.BackgroundPolicy;
290 yield* policy.reportClientActivity(
291 AuthSessionId.make("session-1"),
292 RpcClientId.make(1),
293 makeReport({ focused: false, recentlyInteracted: true }),
294 );
295
296 const snapshot = yield* policy.snapshot;
297 assert.equal(snapshot.activeForegroundLeaseCount, 1);
298 assert.equal(snapshot.shouldRunOpportunisticWork, true);
299 assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), true);
300 }).pipe(Effect.provide(makeLayer(nominalHostPower))),
301 );
302
303 it.effect("pauses scoped work for a visible unfocused client without recent interaction", () =>
304 Effect.gen(function* () {
305 const policy = yield* BackgroundPolicy.BackgroundPolicy;
306 yield* policy.reportClientActivity(
307 AuthSessionId.make("session-1"),
308 RpcClientId.make(1),
309 makeReport({ focused: false, recentlyInteracted: false }),
310 );
311
312 const snapshot = yield* policy.snapshot;
313 assert.equal(snapshot.activeForegroundLeaseCount, 0);
314 assert.equal(snapshot.shouldRunOpportunisticWork, false);
315 assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), false);
316 }).pipe(Effect.provide(makeLayer(nominalHostPower))),
317 );
318
319 it.effect(
320 "performance profile allows background scoped work while a scoped lease is active",
321 () =>
322 Effect.gen(function* () {
323 const policy = yield* BackgroundPolicy.BackgroundPolicy;
324 yield* policy.reportClientActivity(
325 AuthSessionId.make("session-1"),
326 RpcClientId.make(1),
327 makeReport({ focused: false, visible: false }),
328 );
329
330 assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), true);
331 }).pipe(
332 Effect.provide(makeLayer(nominalHostPower, { backgroundActivityProfile: "performance" })),
333 ),
334 );
335
336 it.effect("battery saver profile pauses scoped work on battery", () =>
337 Effect.gen(function* () {
338 const policy = yield* BackgroundPolicy.BackgroundPolicy;
339 yield* policy.reportClientActivity(
340 AuthSessionId.make("session-1"),
341 RpcClientId.make(1),
342 makeReport(),
343 );
344
345 assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), false);
346 }).pipe(
347 Effect.provide(
348 makeLayer(
349 {
350 ...nominalHostPower,
351 onBattery: "true",
352 stale: false,
353 },
354 { backgroundActivityProfile: "battery-saver" },
355 ),
356 ),
357 ),
358 );
359
360 it.effect("does not gate work on stale host power values", () =>
361 Effect.gen(function* () {
362 const policy = yield* BackgroundPolicy.BackgroundPolicy;
363 yield* policy.reportClientActivity(
364 AuthSessionId.make("session-1"),
365 RpcClientId.make(1),
366 makeReport(),
367 );
368
369 assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), true);
370 }).pipe(
371 Effect.provide(
372 makeLayer(
373 {
374 ...nominalHostPower,
375 locked: "true",
376 onBattery: "true",
377 lowPowerMode: "true",
378 thermalState: "critical",
379 stale: true,
380 },
381 { backgroundActivityProfile: "battery-saver" },
382 ),
383 ),
384 ),
385 );
386});
apps/server/src/background/BackgroundPolicy.ts created+349
......@@ -0,0 +1,349 @@
1import {
2 type AuthSessionId,
3 type BackgroundPolicySnapshot,
4 type BackgroundScope,
5 type ClientActivityLease,
6 type ClientActivityReportInput,
7 type HostPowerSnapshot,
8 type RpcClientId,
9} from "@t3tools/contracts";
10import {
11 getBackgroundActivityPresetSettings,
12 resolveServerBackgroundActivitySettings,
13 type ResolvedBackgroundActivitySettings,
14} from "@t3tools/shared/backgroundActivitySettings";
15import * as DateTime from "effect/DateTime";
16import * as Context from "effect/Context";
17import * as Effect from "effect/Effect";
18import * as Layer from "effect/Layer";
19import * as PubSub from "effect/PubSub";
20import * as Ref from "effect/Ref";
21import * as Scope from "effect/Scope";
22import * as Semaphore from "effect/Semaphore";
23import * as Stream from "effect/Stream";
24
25import { ServerSettingsService } from "../serverSettings.ts";
26import { subscribeBeforeSnapshot } from "../utils/subscribeBeforeSnapshot.ts";
27import * as HostPowerMonitor from "./HostPowerMonitor.ts";
28
29export class BackgroundPolicy extends Context.Service<
30 BackgroundPolicy,
31 {
32 readonly reportClientActivity: (
33 sessionId: AuthSessionId,
34 rpcClientId: RpcClientId,
35 input: ClientActivityReportInput,
36 ) => Effect.Effect<void>;
37 readonly removeRpcClient: (
38 sessionId: AuthSessionId,
39 rpcClientId: RpcClientId,
40 ) => Effect.Effect<void>;
41 readonly reportHostPowerState: (snapshot: HostPowerSnapshot) => Effect.Effect<void>;
42 readonly snapshot: Effect.Effect<BackgroundPolicySnapshot>;
43 readonly streamChanges: Stream.Stream<BackgroundPolicySnapshot>;
44 readonly subscribe: Effect.Effect<
45 {
46 readonly latest: BackgroundPolicySnapshot;
47 readonly changes: Stream.Stream<BackgroundPolicySnapshot>;
48 },
49 never,
50 Scope.Scope
51 >;
52 readonly hasDemand: (scope: BackgroundScope) => Effect.Effect<boolean>;
53 readonly shouldRunScopeWork: (scope: BackgroundScope) => Effect.Effect<boolean>;
54 readonly shouldRunOpportunisticWork: Effect.Effect<boolean>;
55 }
56>()("t3/background/BackgroundPolicy") {}
57
58const DEFAULT_LEASE_TTL_MS = 45_000;
59const MAX_LEASE_TTL_MS = 120_000;
60export const MAX_CLIENT_ACTIVITY_LEASES_PER_RPC_CLIENT = 16;
61
62function scopeKey(scope: BackgroundScope): string {
63 switch (scope.type) {
64 case "server-config":
65 case "diagnostics":
66 return scope.type;
67 case "provider-status":
68 return scope.instanceId ? `${scope.type}:${scope.instanceId}` : scope.type;
69 case "vcs-status":
70 case "git-refs":
71 return `${scope.type}:${scope.cwd}`;
72 case "thread":
73 return `${scope.type}:${scope.threadId}`;
74 }
75}
76
77function isLeaseActive(lease: ClientActivityLease, now: DateTime.Utc): boolean {
78 return DateTime.isGreaterThan(lease.expiresAt, now);
79}
80
81function leaseKey(lease: Pick<ClientActivityLease, "sessionId" | "rpcClientId" | "clientId">) {
82 return JSON.stringify([lease.sessionId, lease.rpcClientId, lease.clientId]);
83}
84
85export function upsertClientActivityLease(
86 leases: ReadonlyMap<string, ClientActivityLease>,
87 lease: ClientActivityLease,
88 now: DateTime.Utc,
89): Map<string, ClientActivityLease> {
90 const next = new Map(leases);
91 for (const [key, current] of next) {
92 if (!isLeaseActive(current, now)) {
93 next.delete(key);
94 }
95 }
96
97 const key = leaseKey(lease);
98 if (!next.has(key)) {
99 let connectionLeaseCount = 0;
100 let oldestConnectionLease:
101 | {
102 readonly key: string;
103 readonly updatedAtMs: number;
104 }
105 | undefined;
106 for (const [currentKey, current] of next) {
107 if (current.sessionId !== lease.sessionId || current.rpcClientId !== lease.rpcClientId) {
108 continue;
109 }
110 connectionLeaseCount += 1;
111 const updatedAtMs = DateTime.toEpochMillis(current.updatedAt);
112 if (oldestConnectionLease === undefined || updatedAtMs < oldestConnectionLease.updatedAtMs) {
113 oldestConnectionLease = { key: currentKey, updatedAtMs };
114 }
115 }
116 if (
117 connectionLeaseCount >= MAX_CLIENT_ACTIVITY_LEASES_PER_RPC_CLIENT &&
118 oldestConnectionLease !== undefined
119 ) {
120 next.delete(oldestConnectionLease.key);
121 }
122 }
123
124 next.set(key, lease);
125 return next;
126}
127
128function isForegroundLease(lease: ClientActivityLease, now: DateTime.Utc): boolean {
129 return isLeaseActive(lease, now) && lease.visible && (lease.focused || lease.recentlyInteracted);
130}
131
132function leaseHasScope(lease: ClientActivityLease, scope: BackgroundScope): boolean {
133 const key = scopeKey(scope);
134 return lease.scopes.some((leaseScope) => scopeKey(leaseScope) === key);
135}
136
137function hasThermalPressure(hostPower: HostPowerSnapshot): boolean {
138 return hostPower.thermalState === "serious" || hostPower.thermalState === "critical";
139}
140
141function isHostConstrained(
142 hostPower: HostPowerSnapshot,
143 settings: ResolvedBackgroundActivitySettings,
144): boolean {
145 if (hostPower.stale) return false;
146 if (
147 hostPower.suspended ||
148 (settings.pauseWhenHostLocked && hostPower.locked === "true") ||
149 hasThermalPressure(hostPower)
150 ) {
151 return true;
152 }
153 if (settings.pauseWhenHostLowPower && hostPower.lowPowerMode === "true") return true;
154 return settings.pauseWhenOnBattery && hostPower.onBattery === "true";
155}
156
157function isClientConstrained(
158 lease: ClientActivityLease,
159 settings: ResolvedBackgroundActivitySettings,
160): boolean {
161 if (settings.pauseWhenClientLowPower && lease.lowPowerMode === "true") return true;
162 return settings.pauseWhenOnBattery && lease.batteryState === "unplugged";
163}
164
165function leaseMayRunScopedWork(
166 lease: ClientActivityLease,
167 scope: BackgroundScope,
168 now: DateTime.Utc,
169 settings: ResolvedBackgroundActivitySettings,
170): boolean {
171 const activeWithScope = isLeaseActive(lease, now) && leaseHasScope(lease, scope);
172 if (!activeWithScope || isClientConstrained(lease, settings)) {
173 return false;
174 }
175 if (settings.profile === "performance") {
176 return true;
177 }
178 return isForegroundLease(lease, now);
179}
180
181function computeSnapshot(input: {
182 readonly hostPower: HostPowerSnapshot;
183 readonly leases: ReadonlyMap<string, ClientActivityLease>;
184 readonly now: DateTime.Utc;
185 readonly settings: ResolvedBackgroundActivitySettings;
186 readonly updatedAt: DateTime.Utc;
187}): BackgroundPolicySnapshot {
188 const activeLeases = [...input.leases.values()].filter((lease) =>
189 isLeaseActive(lease, input.now),
190 );
191 const foregroundLeases = activeLeases.filter((lease) => isForegroundLease(lease, input.now));
192 const activeScopeKeys = new Set<string>();
193 for (const lease of activeLeases) {
194 for (const scope of lease.scopes) {
195 activeScopeKeys.add(scopeKey(scope));
196 }
197 }
198
199 return {
200 hostPower: input.hostPower,
201 leases: activeLeases,
202 activeForegroundLeaseCount: foregroundLeases.length,
203 activeScopeKeys: [...activeScopeKeys].toSorted(),
204 shouldRunOpportunisticWork:
205 foregroundLeases.some((lease) => !isClientConstrained(lease, input.settings)) &&
206 !isHostConstrained(input.hostPower, input.settings),
207 updatedAt: input.updatedAt,
208 };
209}
210
211export const make = Effect.fn("background.policy.make")(function* () {
212 const hostPowerMonitor = yield* HostPowerMonitor.HostPowerMonitor;
213 const serverSettings = yield* ServerSettingsService;
214 const leasesRef = yield* Ref.make(new Map<string, ClientActivityLease>());
215 const changes = yield* PubSub.sliding<BackgroundPolicySnapshot>(1);
216 const publishMutex = yield* Semaphore.make(1);
217
218 const backgroundActivitySettings = serverSettings.getSettings.pipe(
219 Effect.map(resolveServerBackgroundActivitySettings),
220 Effect.orElseSucceed(() => getBackgroundActivityPresetSettings("balanced")),
221 );
222
223 const snapshot = Effect.gen(function* () {
224 const [hostPower, leases, now, settings] = yield* Effect.all([
225 hostPowerMonitor.snapshot,
226 Ref.get(leasesRef),
227 DateTime.now,
228 backgroundActivitySettings,
229 ]);
230 return computeSnapshot({ hostPower, leases, now, settings, updatedAt: now });
231 });
232
233 const publishSnapshotUnlocked = snapshot.pipe(
234 Effect.flatMap((next) => PubSub.publish(changes, next)),
235 );
236 const publishSnapshot = publishMutex.withPermits(1)(publishSnapshotUnlocked);
237
238 const reportClientActivity: BackgroundPolicy["Service"]["reportClientActivity"] = (
239 sessionId,
240 rpcClientId,
241 input,
242 ) =>
243 publishMutex.withPermits(1)(
244 Effect.gen(function* () {
245 const ttlMs = Math.min(
246 Math.max(input.ttlMs ?? DEFAULT_LEASE_TTL_MS, 1_000),
247 MAX_LEASE_TTL_MS,
248 );
249 const now = yield* DateTime.now;
250 const expiresAt = DateTime.add(now, { milliseconds: ttlMs });
251 const lease: ClientActivityLease = {
252 sessionId,
253 rpcClientId,
254 clientId: input.clientId,
255 clientKind: input.clientKind,
256 visible: input.visible,
257 focused: input.focused,
258 recentlyInteracted: input.recentlyInteracted,
259 ...(input.appState !== undefined ? { appState: input.appState } : {}),
260 ...(input.lowPowerMode !== undefined ? { lowPowerMode: input.lowPowerMode } : {}),
261 ...(input.batteryState !== undefined ? { batteryState: input.batteryState } : {}),
262 ...(input.networkType !== undefined ? { networkType: input.networkType } : {}),
263 scopes: input.scopes,
264 updatedAt: now,
265 expiresAt,
266 };
267 yield* Ref.update(leasesRef, (leases) => upsertClientActivityLease(leases, lease, now));
268 yield* publishSnapshotUnlocked;
269 }),
270 );
271
272 const removeRpcClient: BackgroundPolicy["Service"]["removeRpcClient"] = (
273 sessionId,
274 rpcClientId,
275 ) =>
276 publishMutex.withPermits(1)(
277 Ref.update(leasesRef, (leases) => {
278 const next = new Map(leases);
279 for (const [key, lease] of next) {
280 if (lease.sessionId === sessionId && lease.rpcClientId === rpcClientId) {
281 next.delete(key);
282 }
283 }
284 return next;
285 }).pipe(Effect.andThen(publishSnapshotUnlocked), Effect.asVoid),
286 );
287
288 const hasDemand: BackgroundPolicy["Service"]["hasDemand"] = (scope) =>
289 Effect.map(snapshot, (current) => current.activeScopeKeys.includes(scopeKey(scope)));
290
291 const shouldRunScopeWork: BackgroundPolicy["Service"]["shouldRunScopeWork"] = (scope) =>
292 Effect.gen(function* () {
293 const [current, settings] = yield* Effect.all([snapshot, backgroundActivitySettings]);
294 if (isHostConstrained(current.hostPower, settings)) {
295 return false;
296 }
297 return current.leases.some((lease) =>
298 leaseMayRunScopedWork(lease, scope, current.updatedAt, settings),
299 );
300 });
301
302 const shouldRunOpportunisticWork = Effect.map(
303 snapshot,
304 (current) => current.shouldRunOpportunisticWork,
305 );
306
307 yield* Stream.runForEach(hostPowerMonitor.streamChanges, () => publishSnapshot).pipe(
308 Effect.forkScoped,
309 );
310 yield* Stream.runForEach(serverSettings.streamChanges, () => publishSnapshot).pipe(
311 Effect.forkScoped,
312 );
313
314 yield* Effect.forever(
315 Effect.sleep("15 seconds").pipe(
316 Effect.andThen(
317 publishMutex.withPermits(1)(
318 Effect.gen(function* () {
319 const now = yield* DateTime.now;
320 yield* Ref.update(leasesRef, (leases) => {
321 const next = new Map(leases);
322 for (const [key, lease] of next) {
323 if (!isLeaseActive(lease, now)) {
324 next.delete(key);
325 }
326 }
327 return next;
328 });
329 yield* publishSnapshotUnlocked;
330 }),
331 ),
332 ),
333 ),
334 ).pipe(Effect.forkScoped);
335
336 return BackgroundPolicy.of({
337 reportClientActivity,
338 removeRpcClient,
339 reportHostPowerState: hostPowerMonitor.report,
340 snapshot,
341 streamChanges: Stream.fromPubSub(changes),
342 subscribe: subscribeBeforeSnapshot(changes, snapshot, publishMutex),
343 hasDemand,
344 shouldRunScopeWork,
345 shouldRunOpportunisticWork,
346 });
347});
348
349export const layer = Layer.effect(BackgroundPolicy, make());
apps/server/src/background/HostPowerMonitor.test.ts created+188
......@@ -0,0 +1,188 @@
1import type { DesktopHostTelemetrySnapshot } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as PubSub from "effect/PubSub";
9import * as Ref from "effect/Ref";
10import * as Stream from "effect/Stream";
11
12import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts";
13import * as HostPowerMonitor from "./HostPowerMonitor.ts";
14
15describe("HostPowerMonitor", () => {
16 it.effect("publishes semantic power changes without idle-time heartbeat churn", () =>
17 Effect.gen(function* () {
18 const monitor = yield* HostPowerMonitor.make();
19 const initial = {
20 source: "electron-main",
21 idle: "false",
22 idleSeconds: 0,
23 locked: "false",
24 suspended: false,
25 onBattery: "false",
26 lowPowerMode: "unknown",
27 thermalState: "nominal",
28 stale: false,
29 updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:00.000Z"),
30 } as const;
31 yield* monitor.report(initial);
32
33 const nextChange = yield* Stream.runHead(monitor.streamChanges).pipe(Effect.forkChild);
34 yield* Effect.yieldNow;
35 yield* monitor.report({
36 ...initial,
37 idleSeconds: 1,
38 updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:01.000Z"),
39 });
40 yield* monitor.report({
41 ...initial,
42 locked: "true",
43 updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:02.000Z"),
44 });
45
46 expect(Option.getOrThrow(yield* Fiber.join(nextChange)).locked).toBe("true");
47 }),
48 );
49
50 it.effect("ignores host power reports older than the latest accepted snapshot", () =>
51 Effect.gen(function* () {
52 const initial = {
53 source: "electron-main",
54 idle: "false",
55 idleSeconds: 0,
56 locked: "false",
57 suspended: false,
58 onBattery: "false",
59 lowPowerMode: "false",
60 thermalState: "nominal",
61 stale: false,
62 updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:00.000Z"),
63 } as const;
64 const monitor = yield* HostPowerMonitor.make(initial);
65 const latestAt = DateTime.makeUnsafe("2026-06-17T12:00:02.000Z");
66 yield* monitor.report({
67 ...initial,
68 locked: "true",
69 updatedAt: latestAt,
70 });
71 yield* monitor.report({
72 ...initial,
73 updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:01.000Z"),
74 });
75
76 const snapshot = yield* monitor.snapshot;
77 expect(snapshot.locked).toBe("true");
78 expect(DateTime.toEpochMillis(snapshot.updatedAt)).toBe(DateTime.toEpochMillis(latestAt));
79 }),
80 );
81
82 it.effect("consumes desktop power directly without retaining diagnostics telemetry", () =>
83 Effect.gen(function* () {
84 const sampledAt = DateTime.makeUnsafe("2026-06-17T12:00:00.000Z");
85 const desktopChanges = yield* PubSub.sliding<DesktopHostTelemetrySnapshot>(1);
86 const diagnosticsDemandWrites = yield* Ref.make(0);
87 const receiverLayer = DesktopTelemetryReceiver.layerTest({
88 changes: Stream.fromPubSub(desktopChanges),
89 setDiagnosticsDemand: () => Ref.update(diagnosticsDemandWrites, (count) => count + 1),
90 });
91 const layer = HostPowerMonitor.layer.pipe(Layer.provide(receiverLayer));
92
93 yield* Effect.gen(function* () {
94 const monitor = yield* HostPowerMonitor.HostPowerMonitor;
95 const nextPower = yield* Stream.runHead(monitor.streamChanges).pipe(Effect.forkChild);
96 yield* Effect.yieldNow;
97 yield* PubSub.publish(desktopChanges, {
98 version: 1,
99 type: "desktopTelemetry",
100 sequence: 1,
101 sampledAtUnixMs: DateTime.toEpochMillis(sampledAt),
102 electronPid: 100,
103 power: {
104 source: "electron-main",
105 idle: "false",
106 idleSeconds: 0,
107 locked: "false",
108 suspended: false,
109 onBattery: "true",
110 lowPowerMode: "unknown",
111 thermalState: "nominal",
112 stale: false,
113 updatedAt: sampledAt,
114 },
115 speedLimitPercent: Option.none(),
116 electronProcesses: [],
117 });
118
119 expect(Option.getOrThrow(yield* Fiber.join(nextPower)).onBattery).toBe("true");
120 expect(yield* Ref.get(diagnosticsDemandWrites)).toBe(0);
121 }).pipe(Effect.provide(layer));
122 }),
123 );
124
125 it.effect(
126 "subscribes before reading the desktop snapshot so concurrent power updates survive",
127 () =>
128 Effect.gen(function* () {
129 const sampledAt = DateTime.makeUnsafe("2026-06-17T12:00:00.000Z");
130 const initial: DesktopHostTelemetrySnapshot = {
131 version: 1,
132 type: "desktopTelemetry",
133 sequence: 1,
134 sampledAtUnixMs: DateTime.toEpochMillis(sampledAt),
135 electronPid: 100,
136 power: {
137 source: "electron-main",
138 idle: "false",
139 idleSeconds: 0,
140 locked: "false",
141 suspended: false,
142 onBattery: "false",
143 lowPowerMode: "unknown",
144 thermalState: "nominal",
145 stale: false,
146 updatedAt: sampledAt,
147 },
148 speedLimitPercent: Option.none(),
149 electronProcesses: [],
150 };
151 const updated: DesktopHostTelemetrySnapshot = {
152 ...initial,
153 sequence: 2,
154 power: {
155 ...initial.power,
156 onBattery: "true",
157 updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:01.000Z"),
158 },
159 };
160 const desktopChanges = yield* PubSub.sliding<DesktopHostTelemetrySnapshot>(1);
161 const receiverLayer = DesktopTelemetryReceiver.layerTest({
162 latest: Effect.succeedSome(initial),
163 changes: Stream.empty,
164 subscribe: Effect.gen(function* () {
165 const subscription = yield* PubSub.subscribe(desktopChanges);
166 yield* PubSub.publish(desktopChanges, updated);
167 return {
168 latest: Option.some(initial),
169 changes: Stream.fromSubscription(subscription),
170 };
171 }),
172 });
173 const layer = HostPowerMonitor.layer.pipe(Layer.provide(receiverLayer));
174
175 yield* Effect.gen(function* () {
176 const monitor = yield* HostPowerMonitor.HostPowerMonitor;
177 for (
178 let attempt = 0;
179 attempt < 100 && (yield* monitor.snapshot).onBattery !== "true";
180 attempt += 1
181 ) {
182 yield* Effect.yieldNow;
183 }
184 expect((yield* monitor.snapshot).onBattery).toBe("true");
185 }).pipe(Effect.provide(layer));
186 }),
187 );
188});
apps/server/src/background/HostPowerMonitor.ts created+101
......@@ -0,0 +1,101 @@
1import type { HostPowerSnapshot } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as PubSub from "effect/PubSub";
8import * as Ref from "effect/Ref";
9import * as Stream from "effect/Stream";
10
11import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts";
12
13export class HostPowerMonitor extends Context.Service<
14 HostPowerMonitor,
15 {
16 readonly snapshot: Effect.Effect<HostPowerSnapshot>;
17 readonly report: (snapshot: HostPowerSnapshot) => Effect.Effect<void>;
18 readonly streamChanges: Stream.Stream<HostPowerSnapshot>;
19 }
20>()("t3/background/HostPowerMonitor") {}
21
22export const makeUnknownSnapshot = (
23 source: HostPowerSnapshot["source"],
24 updatedAt: HostPowerSnapshot["updatedAt"],
25): HostPowerSnapshot => ({
26 source,
27 idle: "unknown",
28 idleSeconds: null,
29 locked: "unknown",
30 suspended: false,
31 onBattery: "unknown",
32 lowPowerMode: "unknown",
33 thermalState: "unknown",
34 stale: true,
35 updatedAt,
36});
37
38function samePowerState(left: HostPowerSnapshot, right: HostPowerSnapshot): boolean {
39 return (
40 left.source === right.source &&
41 left.idle === right.idle &&
42 left.locked === right.locked &&
43 left.suspended === right.suspended &&
44 left.onBattery === right.onBattery &&
45 left.lowPowerMode === right.lowPowerMode &&
46 left.thermalState === right.thermalState &&
47 left.stale === right.stale
48 );
49}
50
51export const make = Effect.fn("background.hostPower.make")(function* (
52 initialSnapshot?: HostPowerSnapshot,
53) {
54 const initial = initialSnapshot ?? makeUnknownSnapshot("unknown", yield* DateTime.now);
55 const latestRef = yield* Ref.make(initial);
56 const changes = yield* PubSub.sliding<HostPowerSnapshot>(1);
57
58 const report: HostPowerMonitor["Service"]["report"] = (snapshot) =>
59 Ref.modify(latestRef, (current) => {
60 if (DateTime.isLessThan(snapshot.updatedAt, current.updatedAt)) {
61 return [Option.none<HostPowerSnapshot>(), current] as const;
62 }
63 return [
64 samePowerState(current, snapshot) ? Option.none() : Option.some(snapshot),
65 snapshot,
66 ] as const;
67 }).pipe(
68 Effect.flatMap(
69 Option.match({
70 onNone: () => Effect.void,
71 onSome: (next) => PubSub.publish(changes, next),
72 }),
73 ),
74 Effect.asVoid,
75 );
76
77 return HostPowerMonitor.of({
78 snapshot: Ref.get(latestRef),
79 report,
80 streamChanges: Stream.fromPubSub(changes),
81 });
82});
83
84export const layer = Layer.effect(
85 HostPowerMonitor,
86 Effect.gen(function* () {
87 const desktopTelemetry = yield* DesktopTelemetryReceiver.DesktopTelemetryReceiver;
88 const desktopSubscription = yield* desktopTelemetry.subscribe;
89 const initial = desktopSubscription.latest;
90 const monitor = yield* Option.match(initial, {
91 onNone: () => make(),
92 onSome: (snapshot) => make(snapshot.power),
93 });
94 yield* desktopSubscription.changes.pipe(
95 Stream.map((snapshot) => snapshot.power),
96 Stream.runForEach(monitor.report),
97 Effect.forkScoped,
98 );
99 return monitor;
100 }),
101);
apps/server/src/cli/config.test.ts+8-1
......@@ -41,7 +41,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
4141 const defaultObservabilityConfig = {
4242 traceMinLevel: "Info",
4343 traceTimingEnabled: true,
44 traceBatchWindowMs: 200,
44 traceBatchWindowMs: 1_000,
4545 traceMaxBytes: 10 * 1024 * 1024,
4646 traceMaxFiles: 10,
4747 otlpTracesUrl: undefined,
......@@ -286,6 +286,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
286286 t3Home: baseDir,
287287 noBrowser: true,
288288 desktopBootstrapToken: "desktop-token",
289 desktopTelemetryFd: 4,
290 desktopTelemetryControlFd: 5,
289291 tailscaleServeEnabled: false,
290292 tailscaleServePort: 443,
291293 otlpTracesUrl: "http://localhost:4318/v1/traces",
......@@ -341,12 +343,17 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
341343 noBrowser: true,
342344 startupPresentation: "browser",
343345 desktopBootstrapToken: "desktop-token",
346 desktopTelemetryFd: 4,
347 desktopTelemetryControlFd: 5,
348 resourceMonitorPath: undefined,
344349 autoBootstrapProjectFromCwd: false,
345350 logWebSocketEvents: false,
346351 tailscaleServeEnabled: false,
347352 tailscaleServePort: 443,
348353 });
349354 assert.equal(join(baseDir, "userdata"), resolved.stateDir);
355 assert.equal(resolved.desktopTelemetryFd, 4);
356 assert.equal(resolved.desktopTelemetryControlFd, 5);
350357 }),
351358 );
352359
apps/server/src/cli/config.ts+7-1
......@@ -85,7 +85,7 @@ const EnvServerConfig = Config.all({
8585 ),
8686 traceMaxBytes: Config.int("T3CODE_TRACE_MAX_BYTES").pipe(Config.withDefault(10 * 1024 * 1024)),
8787 traceMaxFiles: Config.int("T3CODE_TRACE_MAX_FILES").pipe(Config.withDefault(10)),
88 traceBatchWindowMs: Config.int("T3CODE_TRACE_BATCH_WINDOW_MS").pipe(Config.withDefault(200)),
88 traceBatchWindowMs: Config.int("T3CODE_TRACE_BATCH_WINDOW_MS").pipe(Config.withDefault(1_000)),
8989 otlpTracesUrl: Config.string("T3CODE_OTLP_TRACES_URL").pipe(
9090 Config.option,
9191 Config.map(Option.getOrUndefined),
......@@ -303,6 +303,9 @@ export const resolveServerConfig = (
303303 () => mode === "desktop",
304304 );
305305 const desktopBootstrapToken = bootstrap?.desktopBootstrapToken;
306 const desktopTelemetryFd = bootstrap?.desktopTelemetryFd;
307 const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd;
308 const resourceMonitorPath = bootstrap?.resourceMonitorPath;
306309 const autoBootstrapProjectFromCwd = Option.getOrElse(
307310 resolveOptionPrecedence(
308311 Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd),
......@@ -376,6 +379,9 @@ export const resolveServerConfig = (
376379 noBrowser,
377380 startupPresentation,
378381 desktopBootstrapToken,
382 desktopTelemetryFd,
383 desktopTelemetryControlFd,
384 resourceMonitorPath,
379385 autoBootstrapProjectFromCwd,
380386 logWebSocketEvents,
381387 tailscaleServeEnabled,
apps/server/src/config.ts+6
......@@ -76,6 +76,9 @@ export class ServerConfig extends Context.Service<
7676 readonly noBrowser: boolean;
7777 readonly startupPresentation: StartupPresentation;
7878 readonly desktopBootstrapToken: string | undefined;
79 readonly desktopTelemetryFd?: number | undefined;
80 readonly desktopTelemetryControlFd?: number | undefined;
81 readonly resourceMonitorPath?: string | undefined;
7982 readonly autoBootstrapProjectFromCwd: boolean;
8083 readonly logWebSocketEvents: boolean;
8184 readonly tailscaleServeEnabled: boolean;
......@@ -186,6 +189,9 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (
186189 port: 0,
187190 host: undefined,
188191 desktopBootstrapToken: undefined,
192 desktopTelemetryFd: undefined,
193 desktopTelemetryControlFd: undefined,
194 resourceMonitorPath: undefined,
189195 staticDir: undefined,
190196 devUrl,
191197 devAllowedOrigins: [],
apps/server/src/diagnostics/ProcessDiagnostics.test.ts+235-233
......@@ -1,291 +1,293 @@
11import { describe, expect, it } from "@effect/vitest";
2import type {
3 DesktopHostTelemetrySnapshot,
4 ResourceMonitorSnapshotEvent,
5} from "@t3tools/contracts";
26import * as DateTime from "effect/DateTime";
37import * as Effect from "effect/Effect";
48import * as Layer from "effect/Layer";
59import * as Option from "effect/Option";
6import * as Sink from "effect/Sink";
710import * as Stream from "effect/Stream";
8import { ChildProcessSpawner } from "effect/unstable/process";
9import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1011
12import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts";
13import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts";
14import * as ResourceAttribution from "../resourceTelemetry/ResourceAttribution.ts";
15import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts";
1116import * as ProcessDiagnostics from "./ProcessDiagnostics.ts";
1217
13const encoder = new TextEncoder();
18function makeNativeSnapshot(
19 processes: ResourceMonitorSnapshotEvent["processes"],
20): ResourceMonitorSnapshotEvent {
21 return {
22 version: 2,
23 type: "snapshot",
24 sequence: 1,
25 sampledAtUnixMs: DateTime.toEpochMillis(DateTime.makeUnsafe("2026-05-05T10:00:00.000Z")),
26 collectionDurationMicros: 250,
27 scannedProcessCount: processes.length,
28 retainedProcessCount: processes.length,
29 inaccessibleProcessCount: 0,
30 processes,
31 };
32}
1433
15function mockHandle(result: {
16 readonly stdout?: string;
17 readonly stderr?: string;
18 readonly code?: number;
19}) {
20 return ChildProcessSpawner.makeHandle({
21 pid: ChildProcessSpawner.ProcessId(1),
22 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)),
23 isRunning: Effect.succeed(false),
24 kill: () => Effect.void,
25 unref: Effect.succeed(Effect.void),
26 stdin: Sink.drain,
27 stdout: Stream.make(encoder.encode(result.stdout ?? "")),
28 stderr: Stream.make(encoder.encode(result.stderr ?? "")),
29 all: Stream.empty,
30 getInputFd: () => Sink.drain,
31 getOutputFd: () => Stream.empty,
34function makeTelemetryLayer(
35 snapshot: ResourceMonitorSnapshotEvent,
36 desktopSnapshot?: DesktopHostTelemetrySnapshot,
37) {
38 const nativeLayer = NativeTelemetryClient.layerTest({
39 sampleNow: Effect.succeed({ generation: 0, snapshot }),
40 health: Effect.succeed({
41 status: "healthy",
42 hello: Option.none(),
43 lastSampleAt: Option.some(DateTime.makeUnsafe(snapshot.sampledAtUnixMs)),
44 lastError: Option.none(),
45 restartCount: 0,
46 sampleIntervalMs: 1_000,
47 }),
3248 });
49 const desktopLayer = desktopSnapshot
50 ? DesktopTelemetryReceiver.layerTest({
51 latest: Effect.succeedSome(desktopSnapshot),
52 health: Effect.succeed({
53 status: "healthy",
54 lastSampleAt: Option.some(DateTime.makeUnsafe(desktopSnapshot.sampledAtUnixMs)),
55 lastError: Option.none(),
56 }),
57 })
58 : DesktopTelemetryReceiver.layerTest();
59 return ResourceTelemetry.layer.pipe(
60 Layer.provide(Layer.mergeAll(nativeLayer, desktopLayer, ResourceAttribution.layer)),
61 );
3362}
3463
3564describe("ProcessDiagnostics", () => {
36 it.effect("parses POSIX ps rows with full commands", () =>
37 Effect.sync(() => {
38 const rows = ProcessDiagnostics.parsePosixProcessRows(
39 [
40 " 10 1 10 Ss 0.0 1024 01:02.03 /usr/bin/node server.js",
41 " 11 10 10 S+ 12.5 20480 00:04 codex app-server --config /tmp/one two",
42 ].join("\n"),
43 );
44
45 expect(rows).toEqual([
65 it.effect("projects live process data from resource telemetry", () =>
66 Effect.gen(function* () {
67 const snapshot = makeNativeSnapshot([
4668 {
47 pid: 10,
69 pid: process.pid,
4870 ppid: 1,
49 pgid: 10,
50 status: "Ss",
71 startTimeMs: 1_000,
72 runTimeMs: 60_000,
73 name: "node",
74 command: "t3 server",
75 status: "Running",
5176 cpuPercent: 0,
52 rssBytes: 1024 * 1024,
53 elapsed: "01:02.03",
54 command: "/usr/bin/node server.js",
77 cpuTimeMs: 100,
78 residentBytes: 1_024,
79 virtualBytes: 2_048,
80 ioReadBytes: 100,
81 ioWriteBytes: 200,
82 ioSemantics: "storage",
5583 },
5684 {
57 pid: 11,
58 ppid: 10,
59 pgid: 10,
60 status: "S+",
61 cpuPercent: 12.5,
62 rssBytes: 20480 * 1024,
63 elapsed: "00:04",
64 command: "codex app-server --config /tmp/one two",
85 pid: 4_242,
86 ppid: process.pid,
87 startTimeMs: 2_000,
88 runTimeMs: 4_000,
89 name: "agent",
90 command: "codex app-server",
91 status: "Running",
92 cpuPercent: 1.5,
93 cpuTimeMs: 60,
94 residentBytes: 2_048,
95 virtualBytes: 4_096,
96 ioReadBytes: 300,
97 ioWriteBytes: 400,
98 ioSemantics: "storage",
6599 },
66100 ]);
67 }),
68 );
69
70 it.effect("aggregates only descendants of the server process", () =>
71 Effect.sync(() => {
72 const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({
73 serverPid: 100,
74 readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"),
75 rows: [
76 {
77 pid: 100,
78 ppid: 1,
79 pgid: 100,
80 status: "S",
81 cpuPercent: 0,
82 rssBytes: 1_000,
83 elapsed: "01:00",
84 command: "t3 server",
85 },
86 {
87 pid: 101,
88 ppid: 100,
89 pgid: 100,
90 status: "S",
91 cpuPercent: 1.5,
92 rssBytes: 2_000,
93 elapsed: "00:20",
94 command: "codex app-server",
95 },
96 {
97 pid: 102,
98 ppid: 101,
99 pgid: 100,
100 status: "R",
101 cpuPercent: 3.25,
102 rssBytes: 4_000,
103 elapsed: "00:05",
104 command: "git status",
105 },
106 {
107 pid: 200,
108 ppid: 1,
109 pgid: 200,
110 status: "S",
111 cpuPercent: 99,
112 rssBytes: 8_000,
113 elapsed: "00:01",
114 command: "unrelated",
115 },
116 {
117 pid: 201,
118 ppid: 100,
119 pgid: 100,
120 status: "R",
121 cpuPercent: 9,
122 rssBytes: 9_000,
123 elapsed: "00:00",
124 command: "ps -axo pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=",
125 },
126 ],
127 });
101 const telemetryLayer = makeTelemetryLayer(snapshot);
102 const layer = ProcessDiagnostics.layer.pipe(Layer.provideMerge(telemetryLayer));
128103
129 expect(diagnostics.serverPid).toBe(100);
130 expect(DateTime.formatIso(diagnostics.readAt)).toBe("2026-05-05T10:00:00.000Z");
131 expect(diagnostics.processCount).toBe(2);
132 expect(diagnostics.totalRssBytes).toBe(6_000);
133 expect(diagnostics.totalCpuPercent).toBe(4.75);
134 expect(diagnostics.processes.map((process) => process.pid)).toEqual([101, 102]);
135 expect(diagnostics.processes.map((process) => process.depth)).toEqual([0, 1]);
136 expect(Option.getOrNull(diagnostics.processes[0]!.pgid)).toBe(100);
137 expect(diagnostics.processes[0]?.childPids).toEqual([102]);
138 }),
139 );
104 const diagnostics = yield* Effect.gen(function* () {
105 const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;
106 return yield* processDiagnostics.read;
107 }).pipe(Effect.provide(layer));
140108
141 it.effect("preserves ascending sibling order for nested descendants", () =>
142 Effect.sync(() => {
143 const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({
144 serverPid: 100,
145 readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"),
146 rows: [
147 {
148 pid: 101,
149 ppid: 100,
150 pgid: 100,
151 status: "S",
152 cpuPercent: 0,
153 rssBytes: 100,
154 elapsed: "00:10",
155 command: "agent",
156 },
157 {
158 pid: 103,
159 ppid: 101,
160 pgid: 100,
161 status: "S",
162 cpuPercent: 0,
163 rssBytes: 100,
164 elapsed: "00:10",
165 command: "child-b",
166 },
167 {
168 pid: 102,
169 ppid: 101,
170 pgid: 100,
171 status: "S",
172 cpuPercent: 0,
173 rssBytes: 100,
174 elapsed: "00:10",
175 command: "child-a",
176 },
177 ],
178 });
179
180 expect(diagnostics.processes.map((process) => process.pid)).toEqual([101, 102, 103]);
109 expect(diagnostics.processes.map((process) => process.pid)).toEqual([4242]);
110 expect(diagnostics.processes[0]?.startTimeMs).toBe(2_000);
111 expect(diagnostics.processes[0]?.cpuPercent).toBe(1.5);
112 expect(diagnostics.processes[0]?.rssBytes).toBe(2_048);
181113 }),
182114 );
183115
184 it.effect("queries processes through the ChildProcessSpawner service", () =>
116 it.effect("rejects stale process identities before signaling", () =>
185117 Effect.gen(function* () {
186 const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> =
187 [];
188 const spawnerLayer = Layer.succeed(
189 ChildProcessSpawner.ChildProcessSpawner,
190 ChildProcessSpawner.make((command) => {
191 const childProcess = command as unknown as {
192 readonly command: string;
193 readonly args: ReadonlyArray<string>;
194 };
195 commands.push({ command: childProcess.command, args: childProcess.args });
196 return Effect.succeed(
197 mockHandle({
198 stdout: [
199 ` ${process.pid} 1 ${process.pid} Ss 0.0 1024 01:02.03 t3 server`,
200 ` 4242 ${process.pid} ${process.pid} S 1.5 2048 00:04 agent`,
201 ].join("\n"),
202 }),
203 );
204 }),
205 );
206 const layer = ProcessDiagnostics.layer.pipe(Layer.provide(spawnerLayer));
118 const snapshot = makeNativeSnapshot([]);
119 const telemetryLayer = makeTelemetryLayer(snapshot);
120 const layer = ProcessDiagnostics.layer.pipe(Layer.provide(telemetryLayer));
207121
208 const diagnostics = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe(
209 Effect.flatMap((pd) => pd.read),
122 const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe(
123 Effect.flatMap((processDiagnostics) =>
124 processDiagnostics.signal({
125 pid: 4_242,
126 startTimeMs: 2_000,
127 signal: "SIGINT",
128 }),
129 ),
210130 Effect.provide(layer),
211131 );
212132
213 expect(diagnostics.processes.map((process) => process.pid)).toEqual([4242]);
214 expect(commands).toEqual([
215 {
216 command: "ps",
217 args: ["-axo", "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="],
218 },
219 ]);
133 expect(result).toEqual({
134 pid: 4242,
135 signal: "SIGINT",
136 signaled: false,
137 message: Option.some("Process 4242 no longer matches the selected process identity."),
138 });
220139 }),
221140 );
222141
223 it.effect("keeps bounded command diagnostics when the process query exits unsuccessfully", () =>
142 it.effect("refuses to signal when a fresh identity check cannot be collected", () =>
224143 Effect.gen(function* () {
225 const spawnerLayer = Layer.succeed(
226 ChildProcessSpawner.ChildProcessSpawner,
227 ChildProcessSpawner.make(() =>
228 Effect.succeed(
229 mockHandle({
230 code: 17,
231 stdout: "partial process output",
232 stderr: "process access denied",
144 const snapshot = makeNativeSnapshot([
145 {
146 pid: 4_242,
147 ppid: process.pid,
148 startTimeMs: 2_000,
149 runTimeMs: 4_000,
150 name: "agent",
151 command: "codex app-server",
152 status: "Running",
153 cpuPercent: 1.5,
154 cpuTimeMs: 60,
155 residentBytes: 2_048,
156 virtualBytes: 4_096,
157 ioReadBytes: 300,
158 ioWriteBytes: 400,
159 ioSemantics: "storage",
160 },
161 ]);
162 const staleTelemetry = yield* Effect.service(ResourceTelemetry.ResourceTelemetry).pipe(
163 Effect.flatMap((telemetry) => telemetry.latest),
164 Effect.provide(makeTelemetryLayer(snapshot)),
165 );
166 const telemetryLayer = Layer.succeed(
167 ResourceTelemetry.ResourceTelemetry,
168 ResourceTelemetry.ResourceTelemetry.of({
169 latest: Effect.succeed(staleTelemetry),
170 changes: Stream.empty,
171 subscribe: Effect.die("unused"),
172 readHistory: () => Effect.die("unused"),
173 refresh: Effect.fail(
174 new ResourceTelemetry.ResourceTelemetryRefreshFailed({
175 operation: "refresh",
176 cause: new Error("collector unavailable"),
233177 }),
234178 ),
235 ),
179 validateProcessIdentity: () => Effect.die("unused"),
180 retry: Effect.die("unused"),
181 }),
236182 );
183 const layer = ProcessDiagnostics.layer.pipe(Layer.provide(telemetryLayer));
237184
238 const error = yield* ProcessDiagnostics.readProcessRows.pipe(
239 Effect.provide(spawnerLayer),
240 Effect.provideService(HostProcessPlatform, "linux"),
241 Effect.flip,
185 const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe(
186 Effect.flatMap((processDiagnostics) =>
187 processDiagnostics.signal({
188 pid: 4_242,
189 startTimeMs: 2_000,
190 signal: "SIGINT",
191 }),
192 ),
193 Effect.provide(layer),
242194 );
243195
244 expect(error).toMatchObject({
245 _tag: "ProcessDiagnosticsQueryFailedError",
246 command: "ps",
247 argCount: 2,
248 cwd: process.cwd(),
249 exitCode: 17,
250 stdoutBytes: 22,
251 stderrBytes: 21,
252 stdoutTruncated: false,
253 stderrTruncated: false,
196 expect(result).toEqual({
197 pid: 4_242,
198 signal: "SIGINT",
199 signaled: false,
200 message: Option.some(
201 "Could not refresh process 4242; refusing to signal a stale identity.",
202 ),
254203 });
255 expect(error.message).toBe(
256 `Process diagnostics query 'ps' failed with exit code 17 in '${process.cwd()}'.`,
257 );
258204 }),
259205 );
260206
261 it.effect("does not allow signaling the diagnostics query process", () =>
207 it.effect("rejects Electron processes as signal targets", () =>
262208 Effect.gen(function* () {
263 const spawnerLayer = Layer.succeed(
264 ChildProcessSpawner.ChildProcessSpawner,
265 ChildProcessSpawner.make(() =>
266 Effect.succeed(
267 mockHandle({
268 stdout: [
269 ` ${process.pid} 1 ${process.pid} Ss 0.0 1024 01:02.03 t3 server`,
270 ` 4242 ${process.pid} ${process.pid} R 1.5 2048 00:00 ps -axo pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=`,
271 ].join("\n"),
272 }),
273 ),
274 ),
209 const sampledAtUnixMs = DateTime.toEpochMillis(
210 DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"),
275211 );
276 const layer = ProcessDiagnostics.layer.pipe(Layer.provide(spawnerLayer));
212 const snapshot = makeNativeSnapshot([
213 {
214 pid: 4_242,
215 ppid: 1,
216 startTimeMs: 2_000,
217 runTimeMs: 4_000,
218 name: "electron",
219 command: "electron",
220 status: "Running",
221 cpuPercent: 1.5,
222 cpuTimeMs: 60,
223 residentBytes: 2_048,
224 virtualBytes: 4_096,
225 ioReadBytes: 300,
226 ioWriteBytes: 400,
227 ioSemantics: "storage",
228 },
229 ]);
230 const sampledAt = DateTime.makeUnsafe(sampledAtUnixMs);
231 const telemetryLayer = makeTelemetryLayer(snapshot, {
232 version: 1,
233 type: "desktopTelemetry",
234 sequence: 1,
235 sampledAtUnixMs,
236 electronPid: 4_242,
237 power: {
238 source: "electron-main",
239 idle: "false",
240 idleSeconds: 0,
241 locked: "false",
242 suspended: false,
243 onBattery: "false",
244 lowPowerMode: "unknown",
245 thermalState: "nominal",
246 stale: false,
247 updatedAt: sampledAt,
248 },
249 speedLimitPercent: Option.none(),
250 electronProcesses: [
251 {
252 pid: 4_242,
253 creationTimeMs: 2_000,
254 type: "Browser",
255 name: "electron",
256 cpuPercent: 1.5,
257 idleWakeupsPerSecond: 0,
258 workingSetBytes: 2_048,
259 peakWorkingSetBytes: 2_048,
260 },
261 ],
262 });
263 const layer = ProcessDiagnostics.layer.pipe(Layer.provide(telemetryLayer));
277264
278265 const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe(
279 Effect.flatMap((pd) => pd.signal({ pid: 4242, signal: "SIGINT" })),
266 Effect.flatMap((processDiagnostics) =>
267 processDiagnostics.signal({
268 pid: 4_242,
269 startTimeMs: 2_000,
270 signal: "SIGKILL",
271 }),
272 ),
280273 Effect.provide(layer),
281274 );
282275
283276 expect(result).toEqual({
284 pid: 4242,
285 signal: "SIGINT",
277 pid: 4_242,
278 signal: "SIGKILL",
286279 signaled: false,
287 message: Option.some("Process 4242 is not a live descendant of the T3 server."),
280 message: Option.some("Process 4242 is not a signalable T3 backend descendant."),
288281 });
282
283 const diagnostics = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe(
284 Effect.flatMap((processDiagnostics) => processDiagnostics.read),
285 Effect.provide(layer),
286 );
287 expect(diagnostics.processes).toEqual([]);
288 expect(diagnostics.processCount).toBe(0);
289 expect(diagnostics.totalCpuPercent).toBe(0);
290 expect(diagnostics.totalRssBytes).toBe(0);
289291 }),
290292 );
291293});
apps/server/src/diagnostics/ProcessDiagnostics.ts+116-523
......@@ -1,105 +1,20 @@
11import type {
2 ResourceTelemetryProcessCategory,
23 ServerProcessDiagnosticsEntry,
34 ServerProcessDiagnosticsResult,
45 ServerProcessSignal,
56 ServerSignalProcessResult,
67} from "@t3tools/contracts";
7import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
88import * as Context from "effect/Context";
9import * as DateTime from "effect/DateTime";
10import * as Duration from "effect/Duration";
119import * as Effect from "effect/Effect";
1210import * as Layer from "effect/Layer";
1311import * as Option from "effect/Option";
1412import * as Schema from "effect/Schema";
15import * as ChildProcess from "effect/unstable/process/ChildProcess";
16import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1713
18import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
14import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts";
1915
20export interface ProcessRow {
21 readonly pid: number;
22 readonly ppid: number;
23 readonly pgid: number | null;
24 readonly status: string;
25 readonly cpuPercent: number;
26 readonly rssBytes: number;
27 readonly elapsed: string;
28 readonly command: string;
29}
30
31const PROCESS_QUERY_TIMEOUT_MS = 1_000;
32const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=";
33const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
34
35export class ProcessDiagnostics extends Context.Service<
36 ProcessDiagnostics,
37 {
38 readonly read: Effect.Effect<ServerProcessDiagnosticsResult>;
39 readonly signal: (input: {
40 readonly pid: number;
41 readonly signal: ServerProcessSignal;
42 }) => Effect.Effect<ServerSignalProcessResult>;
43 }
44>()("t3/diagnostics/ProcessDiagnostics") {}
45
46class ProcessDiagnosticsQueryTimeoutError extends Schema.TaggedErrorClass<ProcessDiagnosticsQueryTimeoutError>()(
47 "ProcessDiagnosticsQueryTimeoutError",
48 {
49 command: Schema.String,
50 argCount: Schema.Number,
51 cwd: Schema.String,
52 timeoutMillis: Schema.Number,
53 },
54) {
55 override get message(): string {
56 return `Process diagnostics query '${this.command}' timed out after ${this.timeoutMillis}ms in '${this.cwd}'.`;
57 }
58}
59
60class ProcessDiagnosticsQueryFailedError extends Schema.TaggedErrorClass<ProcessDiagnosticsQueryFailedError>()(
61 "ProcessDiagnosticsQueryFailedError",
62 {
63 command: Schema.String,
64 argCount: Schema.Number,
65 cwd: Schema.String,
66 exitCode: Schema.optional(Schema.Number),
67 stdoutBytes: Schema.optional(Schema.Number),
68 stderrBytes: Schema.optional(Schema.Number),
69 stdoutTruncated: Schema.optional(Schema.Boolean),
70 stderrTruncated: Schema.optional(Schema.Boolean),
71 cause: Schema.optional(Schema.Defect()),
72 },
73) {
74 override get message(): string {
75 const exitCode = this.exitCode === undefined ? "" : ` with exit code ${this.exitCode}`;
76 return `Process diagnostics query '${this.command}' failed${exitCode} in '${this.cwd}'.`;
77 }
78}
79
80class ProcessDiagnosticsServerProcessSignalError extends Schema.TaggedErrorClass<ProcessDiagnosticsServerProcessSignalError>()(
81 "ProcessDiagnosticsServerProcessSignalError",
82 { pid: Schema.Number },
83) {
84 override get message(): string {
85 return "Refusing to signal the T3 server process.";
86 }
87}
88
89class ProcessDiagnosticsNotDescendantError extends Schema.TaggedErrorClass<ProcessDiagnosticsNotDescendantError>()(
90 "ProcessDiagnosticsNotDescendantError",
91 {
92 pid: Schema.Number,
93 serverPid: Schema.Number,
94 },
95) {
96 override get message(): string {
97 return `Process ${this.pid} is not a live descendant of the T3 server.`;
98 }
99}
100
101class ProcessDiagnosticsSignalFailedError extends Schema.TaggedErrorClass<ProcessDiagnosticsSignalFailedError>()(
102 "ProcessDiagnosticsSignalFailedError",
16export class ProcessSignalFailed extends Schema.TaggedErrorClass<ProcessSignalFailed>()(
17 "ProcessSignalFailed",
10318 {
10419 pid: Schema.Number,
10520 signal: Schema.String,
......@@ -111,458 +26,136 @@ class ProcessDiagnosticsSignalFailedError extends Schema.TaggedErrorClass<Proces
11126 }
11227}
11328
114const ProcessDiagnosticsError = Schema.Union([
115 ProcessDiagnosticsQueryTimeoutError,
116 ProcessDiagnosticsQueryFailedError,
117 ProcessDiagnosticsServerProcessSignalError,
118 ProcessDiagnosticsNotDescendantError,
119 ProcessDiagnosticsSignalFailedError,
120]);
121type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type;
122const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError);
123
124function parsePositiveInt(value: string): number | null {
125 const parsed = Number.parseInt(value, 10);
126 return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
127}
128
129function parseNonNegativeInt(value: string): number | null {
130 const parsed = Number.parseInt(value, 10);
131 return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
132}
133
134function parseNumber(value: string): number | null {
135 const parsed = Number.parseFloat(value);
136 return Number.isFinite(parsed) ? parsed : null;
137}
138
139export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow> {
140 const rows: ProcessRow[] = [];
141 const rowPattern =
142 /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(\S+)\s+([+-]?(?:\d+\.?\d*|\.\d+))\s+(\d+)\s+(\S+)\s+(.+)$/;
143
144 for (const line of output.split(/\r?\n/)) {
145 if (line.trim().length === 0) continue;
146
147 const match = rowPattern.exec(line);
148 if (!match) continue;
149
150 const pidText = match[1];
151 const ppidText = match[2];
152 const pgidText = match[3];
153 const status = match[4];
154 const cpuText = match[5];
155 const rssText = match[6];
156 const elapsed = match[7];
157 const command = match[8];
158 if (
159 pidText === undefined ||
160 ppidText === undefined ||
161 pgidText === undefined ||
162 status === undefined ||
163 cpuText === undefined ||
164 rssText === undefined ||
165 elapsed === undefined ||
166 command === undefined
167 ) {
168 continue;
169 }
170
171 const pid = parsePositiveInt(pidText);
172 const ppid = parseNonNegativeInt(ppidText);
173 const pgid = Number.parseInt(pgidText, 10);
174 const cpuPercent = parseNumber(cpuText);
175 const rssKiB = parseNonNegativeInt(rssText);
176 if (
177 pid === null ||
178 ppid === null ||
179 !Number.isInteger(pgid) ||
180 cpuPercent === null ||
181 rssKiB === null ||
182 !status ||
183 !elapsed ||
184 !command
185 ) {
186 continue;
187 }
188
189 rows.push({
190 pid,
191 ppid,
192 pgid,
193 status,
194 cpuPercent,
195 rssBytes: rssKiB * 1024,
196 elapsed,
197 command,
198 });
199 }
200
201 return rows;
202}
203
204function normalizeWindowsProcessRow(value: unknown): ProcessRow | null {
205 if (typeof value !== "object" || value === null) return null;
206 const record = value as Record<string, unknown>;
207 const pid = typeof record.ProcessId === "number" ? record.ProcessId : null;
208 const ppid = typeof record.ParentProcessId === "number" ? record.ParentProcessId : null;
209 const commandLine =
210 typeof record.CommandLine === "string" && record.CommandLine.trim().length > 0
211 ? record.CommandLine
212 : typeof record.Name === "string"
213 ? record.Name
214 : null;
215 const workingSet =
216 typeof record.WorkingSetSize === "number" && Number.isFinite(record.WorkingSetSize)
217 ? Math.max(0, Math.round(record.WorkingSetSize))
218 : 0;
219 const cpuPercent =
220 typeof record.PercentProcessorTime === "number" && Number.isFinite(record.PercentProcessorTime)
221 ? Math.max(0, record.PercentProcessorTime)
222 : 0;
223
224 if (!pid || pid <= 0 || ppid === null || ppid < 0 || !commandLine) return null;
225 return {
226 pid,
227 ppid,
228 pgid: null,
229 status: typeof record.Status === "string" && record.Status.length > 0 ? record.Status : "Live",
230 cpuPercent,
231 rssBytes: workingSet,
232 elapsed: "",
233 command: commandLine,
234 };
235}
236
237function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> {
238 if (output.trim().length === 0) return [];
239 try {
240 const parsed = JSON.parse(output) as unknown;
241 const records = Array.isArray(parsed) ? parsed : [parsed];
242 return records.flatMap((record) => {
243 const row = normalizeWindowsProcessRow(record);
244 return row ? [row] : [];
245 });
246 } catch {
247 return [];
248 }
249}
250
251export function buildDescendantEntries(
252 rows: ReadonlyArray<ProcessRow>,
253 serverPid: number,
254): ReadonlyArray<ServerProcessDiagnosticsEntry> {
255 const childrenByParent = new Map<number, ProcessRow[]>();
256 for (const row of rows) {
257 const children = childrenByParent.get(row.ppid) ?? [];
258 children.push(row);
259 childrenByParent.set(row.ppid, children);
260 }
261
262 const entries: ServerProcessDiagnosticsEntry[] = [];
263 const visited = new Set<number>();
264 const stack = [...(childrenByParent.get(serverPid) ?? [])]
265 .toSorted((left, right) => left.pid - right.pid)
266 .map((row) => ({ row, depth: 0 }));
267
268 while (stack.length > 0) {
269 const item = stack.shift();
270 if (!item || visited.has(item.row.pid)) continue;
271 visited.add(item.row.pid);
272
273 const children = [...(childrenByParent.get(item.row.pid) ?? [])].toSorted(
274 (left, right) => left.pid - right.pid,
275 );
276 entries.push({
277 pid: item.row.pid,
278 ppid: item.row.ppid,
279 pgid: Option.fromNullishOr(item.row.pgid),
280 status: item.row.status,
281 cpuPercent: item.row.cpuPercent,
282 rssBytes: item.row.rssBytes,
283 elapsed: item.row.elapsed || "n/a",
284 command: item.row.command,
285 depth: item.depth,
286 childPids: children.map((child) => child.pid),
287 });
288
289 stack.unshift(...children.map((row) => ({ row, depth: item.depth + 1 })));
29export class ProcessDiagnostics extends Context.Service<
30 ProcessDiagnostics,
31 {
32 readonly read: Effect.Effect<ServerProcessDiagnosticsResult>;
33 readonly signal: (input: {
34 readonly pid: number;
35 readonly startTimeMs: number;
36 readonly signal: ServerProcessSignal;
37 }) => Effect.Effect<ServerSignalProcessResult>;
29038 }
39>()("t3/diagnostics/ProcessDiagnostics") {}
29140
292 return entries;
41function formatElapsed(runTimeMs: number): string {
42 const totalSeconds = Math.max(0, Math.floor(runTimeMs / 1_000));
43 const hours = Math.floor(totalSeconds / 3_600);
44 const minutes = Math.floor((totalSeconds % 3_600) / 60);
45 const seconds = totalSeconds % 60;
46 return hours > 0
47 ? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
48 : `${minutes}:${String(seconds).padStart(2, "0")}`;
29349}
29450
295export function isDiagnosticsQueryProcess(row: ProcessRow, serverPid: number): boolean {
296 if (row.ppid !== serverPid) return false;
297
298 const command = row.command.trim();
51function canSignalCategory(category: ResourceTelemetryProcessCategory): boolean {
29952 return (
300 /(?:^|[/\\])ps\s+-axo\s+pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=/.test(command) ||
301 (/\bpowershell(?:\.exe)?\b/i.test(command) &&
302 /\bGet-CimInstance\s+Win32_Process\b/i.test(command))
53 category === "server-child" || category === "provider-root" || category === "terminal-root"
30354 );
30455}
30556
306function makeResult(input: {
307 readonly serverPid: number;
308 readonly rows: ReadonlyArray<ProcessRow>;
309 readonly readAt: DateTime.Utc;
310 readonly error?: string;
311}): ServerProcessDiagnosticsResult {
312 const readAt = input.readAt;
313 const rows = input.rows.filter((row) => !isDiagnosticsQueryProcess(row, input.serverPid));
314 const processes = buildDescendantEntries(rows, input.serverPid);
315 const totalRssBytes = processes.reduce((total, process) => total + process.rssBytes, 0);
316 const totalCpuPercent = processes.reduce((total, process) => total + process.cpuPercent, 0);
317
318 return {
319 serverPid: input.serverPid,
320 readAt,
321 processCount: processes.length,
322 totalRssBytes,
323 totalCpuPercent,
324 processes,
325 error: input.error ? Option.some({ message: input.error }) : Option.none(),
326 };
327}
328
329interface ProcessOutput {
330 readonly cwd: string;
331 readonly exitCode: number;
332 readonly stdout: string;
333 readonly stdoutBytes: number;
334 readonly stdoutTruncated: boolean;
335 readonly stderr: string;
336 readonly stderrBytes: number;
337 readonly stderrTruncated: boolean;
338}
339
340const runProcess = Effect.fn("runProcess")(function* (input: {
341 readonly command: string;
342 readonly args: ReadonlyArray<string>;
343}) {
344 const cwd = process.cwd();
345 return yield* Effect.gen(function* () {
346 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
347 // `ps` and `powershell.exe` are real executables; spawning through cmd.exe
348 // shell mode would re-tokenize the PowerShell `-Command` payload (which
349 // contains pipes) before PowerShell ever sees it.
350 const child = yield* spawner.spawn(
351 ChildProcess.make(input.command, input.args, {
352 cwd,
353 }),
354 );
355 const [stdout, stderr, exitCode] = yield* Effect.all(
356 [
357 collectUint8StreamText({
358 stream: child.stdout,
359 maxBytes: PROCESS_QUERY_MAX_OUTPUT_BYTES,
360 truncatedMarker: "\n\n[truncated]",
361 }),
362 collectUint8StreamText({
363 stream: child.stderr,
364 maxBytes: PROCESS_QUERY_MAX_OUTPUT_BYTES,
365 truncatedMarker: "\n\n[truncated]",
366 }),
367 child.exitCode,
368 ],
369 { concurrency: "unbounded" },
370 );
371
372 return {
373 cwd,
374 exitCode,
375 stdout: stdout.text,
376 stdoutBytes: stdout.bytes,
377 stdoutTruncated: stdout.truncated,
378 stderr: stderr.text,
379 stderrBytes: stderr.bytes,
380 stderrTruncated: stderr.truncated,
381 } satisfies ProcessOutput;
382 }).pipe(
383 Effect.scoped,
384 Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)),
385 Effect.flatMap((result) =>
386 Option.match(result, {
387 onNone: () =>
388 Effect.fail(
389 new ProcessDiagnosticsQueryTimeoutError({
390 command: input.command,
391 argCount: input.args.length,
392 cwd,
393 timeoutMillis: PROCESS_QUERY_TIMEOUT_MS,
394 }),
395 ),
396 onSome: Effect.succeed,
397 }),
398 ),
399 Effect.mapError((cause) =>
400 isProcessDiagnosticsError(cause)
401 ? cause
402 : new ProcessDiagnosticsQueryFailedError({
403 command: input.command,
404 argCount: input.args.length,
405 cwd,
406 cause,
57export const make = Effect.fn("makeProcessDiagnostics")(function* () {
58 const telemetry = yield* ResourceTelemetry.ResourceTelemetry;
59 const refreshedTelemetry = telemetry.refresh.pipe(Effect.catch(() => telemetry.latest));
60 const read: ProcessDiagnostics["Service"]["read"] = refreshedTelemetry.pipe(
61 Effect.map((snapshot) => {
62 const processes = snapshot.processes
63 .filter((entry) => canSignalCategory(entry.category))
64 .map(
65 (entry): ServerProcessDiagnosticsEntry => ({
66 pid: entry.identity.pid,
67 startTimeMs: entry.identity.startTimeMs,
68 ppid: entry.ppid,
69 pgid: Option.none(),
70 status: entry.status || "Unknown",
71 cpuPercent: entry.cpuPercent,
72 rssBytes: entry.residentBytes,
73 elapsed: formatElapsed(entry.runTimeMs),
74 command: entry.command || entry.name || "unknown",
75 depth: Math.max(0, entry.depth - 1),
76 childPids: entry.childPids,
40777 }),
408 ),
409 );
410});
411
412function readPosixProcessRows(): Effect.Effect<
413 ReadonlyArray<ProcessRow>,
414 ProcessDiagnosticsError,
415 ChildProcessSpawner.ChildProcessSpawner
416> {
417 return runProcess({
418 command: "ps",
419 args: ["-axo", POSIX_PROCESS_QUERY_COMMAND],
420 }).pipe(
421 Effect.flatMap((result) =>
422 result.exitCode !== 0
423 ? Effect.fail(
424 new ProcessDiagnosticsQueryFailedError({
425 command: "ps",
426 argCount: 2,
427 cwd: result.cwd,
428 exitCode: result.exitCode,
429 stdoutBytes: result.stdoutBytes,
430 stderrBytes: result.stderrBytes,
431 stdoutTruncated: result.stdoutTruncated,
432 stderrTruncated: result.stderrTruncated,
433 }),
434 )
435 : Effect.succeed(parsePosixProcessRows(result.stdout)),
436 ),
437 );
438}
439
440function readWindowsProcessRows(): Effect.Effect<
441 ReadonlyArray<ProcessRow>,
442 ProcessDiagnosticsError,
443 ChildProcessSpawner.ChildProcessSpawner
444> {
445 const command = [
446 "$processes = Get-CimInstance Win32_Process | ForEach-Object {",
447 '$perf = Get-CimInstance Win32_PerfFormattedData_PerfProc_Process -Filter "IDProcess = $($_.ProcessId)" -ErrorAction SilentlyContinue;',
448 "[pscustomobject]@{ ProcessId = $_.ProcessId; ParentProcessId = $_.ParentProcessId; Name = $_.Name; CommandLine = $_.CommandLine; Status = $_.Status; WorkingSetSize = $_.WorkingSetSize; PercentProcessorTime = if ($perf) { $perf.PercentProcessorTime } else { 0 } }",
449 "};",
450 "$processes | ConvertTo-Json -Compress -Depth 3",
451 ].join(" ");
452
453 return runProcess({
454 command: "powershell.exe",
455 args: ["-NoProfile", "-NonInteractive", "-Command", command],
456 }).pipe(
457 Effect.flatMap((result) =>
458 result.exitCode !== 0
459 ? Effect.fail(
460 new ProcessDiagnosticsQueryFailedError({
461 command: "powershell.exe",
462 argCount: 4,
463 cwd: result.cwd,
464 exitCode: result.exitCode,
465 stdoutBytes: result.stdoutBytes,
466 stderrBytes: result.stderrBytes,
467 stdoutTruncated: result.stdoutTruncated,
468 stderrTruncated: result.stderrTruncated,
469 }),
470 )
471 : Effect.succeed(parseWindowsProcessRows(result.stdout)),
472 ),
473 );
474}
475
476export const readProcessRows = Effect.gen(function* () {
477 const platform = yield* HostProcessPlatform;
478 return yield* platform === "win32" ? readWindowsProcessRows() : readPosixProcessRows();
479});
480
481export function aggregateProcessDiagnostics(input: {
482 readonly serverPid: number;
483 readonly rows: ReadonlyArray<ProcessRow>;
484 readonly readAt: DateTime.Utc;
485}): ServerProcessDiagnosticsResult {
486 return makeResult(input);
487}
488
489function assertDescendantPid(
490 pid: number,
491): Effect.Effect<void, ProcessDiagnosticsError, ChildProcessSpawner.ChildProcessSpawner> {
492 if (pid === process.pid) {
493 return Effect.fail(
494 new ProcessDiagnosticsServerProcessSignalError({
495 pid,
496 }),
497 );
498 }
499
500 return readProcessRows.pipe(
501 Effect.flatMap((rows) => {
502 const filteredRows = rows.filter((row) => !isDiagnosticsQueryProcess(row, process.pid));
503 const descendant = buildDescendantEntries(filteredRows, process.pid).some(
504 (entry) => entry.pid === pid,
505 );
506 return descendant
507 ? Effect.void
508 : Effect.fail(
509 new ProcessDiagnosticsNotDescendantError({
510 pid,
511 serverPid: process.pid,
512 }),
513 );
78 );
79 return {
80 serverPid: process.pid,
81 readAt: snapshot.readAt,
82 processCount: processes.length,
83 totalRssBytes: processes.reduce((total, entry) => total + entry.rssBytes, 0),
84 totalCpuPercent: processes.reduce((total, entry) => total + entry.cpuPercent, 0),
85 processes,
86 error: Option.map(snapshot.health.native.lastError, (message) => ({ message })),
87 };
51488 }),
51589 );
516}
517
518export const make = Effect.gen(function* () {
519 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
520
521 const read: ProcessDiagnostics["Service"]["read"] = Effect.gen(function* () {
522 const readAt = yield* DateTime.now;
523 const rows = yield* readProcessRows.pipe(
524 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
525 );
526 return makeResult({ serverPid: process.pid, rows, readAt });
527 }).pipe(
528 Effect.catch((error: ProcessDiagnosticsError) =>
529 DateTime.now.pipe(
530 Effect.map((readAt) =>
531 makeResult({ serverPid: process.pid, rows: [], readAt, error: error.message }),
532 ),
533 ),
534 ),
535 );
53690
53791 const signal: ProcessDiagnostics["Service"]["signal"] = Effect.fn("ProcessDiagnostics.signal")(
53892 function* (input) {
539 return yield* assertDescendantPid(input.pid).pipe(
540 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
541 Effect.flatMap(() =>
542 Effect.try({
543 try: () => {
544 process.kill(input.pid, input.signal);
545 return {
546 pid: input.pid,
547 signal: input.signal,
548 signaled: true,
549 message: Option.none(),
550 };
551 },
552 catch: (cause) =>
553 new ProcessDiagnosticsSignalFailedError({
554 pid: input.pid,
555 signal: input.signal,
556 cause,
557 }),
93 if (input.pid === process.pid) {
94 return {
95 pid: input.pid,
96 signal: input.signal,
97 signaled: false,
98 message: Option.some("Refusing to signal the T3 server process."),
99 };
100 }
101 const current = yield* telemetry.refresh.pipe(Effect.option);
102 if (Option.isNone(current)) {
103 return {
104 pid: input.pid,
105 signal: input.signal,
106 signaled: false,
107 message: Option.some(
108 `Could not refresh process ${input.pid}; refusing to signal a stale identity.`,
109 ),
110 };
111 }
112 const selected = current.value.processes.find(
113 (entry) =>
114 entry.identity.pid === input.pid && entry.identity.startTimeMs === input.startTimeMs,
115 );
116 if (!selected) {
117 return {
118 pid: input.pid,
119 signal: input.signal,
120 signaled: false,
121 message: Option.some(
122 `Process ${input.pid} no longer matches the selected process identity.`,
123 ),
124 };
125 }
126 if (!canSignalCategory(selected.category)) {
127 return {
128 pid: input.pid,
129 signal: input.signal,
130 signaled: false,
131 message: Option.some(`Process ${input.pid} is not a signalable T3 backend descendant.`),
132 };
133 }
134 return yield* Effect.try({
135 try: () => {
136 process.kill(input.pid, input.signal);
137 return {
138 pid: input.pid,
139 signal: input.signal,
140 signaled: true,
141 message: Option.none(),
142 };
143 },
144 catch: (cause) =>
145 new ProcessSignalFailed({
146 pid: input.pid,
147 signal: input.signal,
148 cause,
558149 }),
559 ),
560 Effect.catch((error: ProcessDiagnosticsError) =>
150 }).pipe(
151 Effect.catch((error) =>
561152 Effect.succeed({
562153 pid: input.pid,
563154 signal: input.signal,
564155 signaled: false,
565 message: Option.some(error.message),
156 message: Option.some(
157 error instanceof Error ? error.message : "Failed to signal process.",
158 ),
566159 }),
567160 ),
568161 );
......@@ -572,4 +165,4 @@ export const make = Effect.gen(function* () {
572165 return ProcessDiagnostics.of({ read, signal });
573166});
574167
575export const layer = Layer.effect(ProcessDiagnostics, make);
168export const layer = Layer.effect(ProcessDiagnostics, make());
apps/server/src/diagnostics/ProcessResourceMonitor.test.ts+145-230
......@@ -1,255 +1,170 @@
11import { describe, expect, it } from "@effect/vitest";
22import * as DateTime from "effect/DateTime";
33import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
45import * as Option from "effect/Option";
6import * as Stream from "effect/Stream";
57
8import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts";
9import type { ResourceTelemetryHistoryWithLegacyBuckets } from "../resourceTelemetry/ResourceTelemetryHistory.ts";
610import * as ProcessResourceMonitor from "./ProcessResourceMonitor.ts";
711
812describe("ProcessResourceMonitor", () => {
9 it.effect("samples the server root process and descendants", () =>
10 Effect.sync(() => {
11 const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
12 const samples = ProcessResourceMonitor.collectMonitoredSamples({
13 serverPid: 100,
14 sampledAt,
15 sampledAtMs: DateTime.toEpochMillis(sampledAt),
16 rows: [
17 {
18 pid: 100,
19 ppid: 1,
20 pgid: 100,
21 status: "S",
22 cpuPercent: 2,
23 rssBytes: 1_000,
24 elapsed: "01:00",
25 command: "t3 server",
26 },
13 it.effect("projects resource telemetry history into the legacy diagnostics contract", () =>
14 Effect.gen(function* () {
15 const readAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
16 const history: ResourceTelemetryHistoryWithLegacyBuckets = {
17 readAt,
18 windowMs: 60_000,
19 bucketMs: 10_000,
20 sampleIntervalMs: 1_000,
21 retainedSampleCount: 2,
22 buckets: [
2723 {
28 pid: 101,
29 ppid: 100,
30 pgid: 100,
31 status: "S",
32 cpuPercent: 10,
33 rssBytes: 2_000,
34 elapsed: "00:20",
35 command: "codex app-server",
24 startedAt: DateTime.makeUnsafe("2026-05-05T09:59:50.000Z"),
25 endedAt: readAt,
26 avgCpuPercent: 15,
27 maxCpuPercent: 25,
28 maxRssBytes: 4_096,
29 ioReadBytes: 1_024,
30 ioWriteBytes: 2_048,
31 maxProcessCount: 2,
3632 },
33 ],
34 legacyBackendBuckets: [
3735 {
38 pid: 102,
39 ppid: 101,
40 pgid: 100,
41 status: "R",
42 cpuPercent: 50,
43 rssBytes: 3_000,
44 elapsed: "00:05",
45 command: "rg needle",
36 startedAt: DateTime.makeUnsafe("2026-05-05T09:59:50.000Z"),
37 endedAt: readAt,
38 avgCpuPercent: 5,
39 maxCpuPercent: 8,
40 maxRssBytes: 4_096,
41 ioReadBytes: 1_024,
42 ioWriteBytes: 2_048,
43 maxProcessCount: 1,
4644 },
45 ],
46 topProcesses: [
4747 {
48 pid: 200,
48 identity: { pid: process.pid, startTimeMs: 100 },
4949 ppid: 1,
50 pgid: 200,
51 status: "R",
52 cpuPercent: 99,
53 rssBytes: 9_000,
54 elapsed: "00:05",
55 command: "unrelated",
50 depth: 0,
51 name: "node",
52 command: "t3 server",
53 category: "server",
54 firstSeenAt: DateTime.makeUnsafe("2026-05-05T09:59:55.000Z"),
55 lastSeenAt: readAt,
56 currentCpuPercent: 5,
57 avgCpuPercent: 4,
58 maxCpuPercent: 8,
59 cpuTimeMs: 1_500,
60 currentRssBytes: 2_048,
61 peakRssBytes: 4_096,
62 ioReadBytes: 1_024,
63 ioWriteBytes: 2_048,
64 ioSemantics: "storage",
65 sampleCount: 2,
5666 },
57 ],
58 });
59
60 expect(samples.map((sample) => sample.pid)).toEqual([100, 101, 102]);
61 expect(samples.map((sample) => sample.depth)).toEqual([0, 1, 2]);
62 expect(samples[0]?.isServerRoot).toBe(true);
63 expect(samples[1]?.isServerRoot).toBe(false);
64 }),
65 );
66
67 it.effect("rolls samples up by process and CPU time", () =>
68 Effect.sync(() => {
69 const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
70 const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.000Z");
71 const samples = [
72 ...ProcessResourceMonitor.collectMonitoredSamples({
73 serverPid: 100,
74 sampledAt: firstAt,
75 sampledAtMs: DateTime.toEpochMillis(firstAt),
76 rows: [
77 {
78 pid: 100,
79 ppid: 1,
80 pgid: 100,
81 status: "S",
82 cpuPercent: 10,
83 rssBytes: 1_000,
84 elapsed: "01:00",
85 command: "t3 server",
86 },
87 ],
88 }),
89 ...ProcessResourceMonitor.collectMonitoredSamples({
90 serverPid: 100,
91 sampledAt: secondAt,
92 sampledAtMs: DateTime.toEpochMillis(secondAt),
93 rows: [
94 {
95 pid: 100,
96 ppid: 1,
97 pgid: 100,
98 status: "S",
99 cpuPercent: 30,
100 rssBytes: 2_000,
101 elapsed: "01:05",
102 command: "t3 server",
103 },
104 ],
105 }),
106 ];
107
108 const result = ProcessResourceMonitor.aggregateProcessResourceHistory({
109 samples,
110 readAt: secondAt,
111 readAtMs: DateTime.toEpochMillis(secondAt),
112 windowMs: 60_000,
113 bucketMs: 10_000,
114 lastFailure: null,
115 });
116
117 expect(Option.isNone(result.error)).toBe(true);
118 expect(result.topProcesses).toHaveLength(1);
119 expect(result.topProcesses[0]?.avgCpuPercent).toBe(20);
120 expect(result.topProcesses[0]?.maxCpuPercent).toBe(30);
121 expect(result.topProcesses[0]?.cpuSecondsApprox).toBe(2);
122 expect(result.totalCpuSecondsApprox).toBe(2);
123 expect(result.buckets.some((bucket) => bucket.maxCpuPercent === 30)).toBe(true);
124 }),
125 );
126
127 it.effect("keeps a process grouped when elapsed time drifts between samples", () =>
128 Effect.sync(() => {
129 const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.400Z");
130 const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.900Z");
131 const samples = [
132 ...ProcessResourceMonitor.collectMonitoredSamples({
133 serverPid: 100,
134 sampledAt: firstAt,
135 sampledAtMs: DateTime.toEpochMillis(firstAt),
136 rows: [
137 {
138 pid: 100,
139 ppid: 1,
140 pgid: 100,
141 status: "S",
142 cpuPercent: 1,
143 rssBytes: 1_000,
144 elapsed: "01:00",
145 command: "t3 server",
146 },
147 ],
148 }),
149 ...ProcessResourceMonitor.collectMonitoredSamples({
150 serverPid: 100,
151 sampledAt: secondAt,
152 sampledAtMs: DateTime.toEpochMillis(secondAt),
153 rows: [
154 {
155 pid: 100,
156 ppid: 1,
157 pgid: 100,
158 status: "S",
159 cpuPercent: 2,
160 rssBytes: 2_000,
161 elapsed: "01:06",
162 command: "t3 server",
163 },
164 ],
165 }),
166 ];
167
168 const result = ProcessResourceMonitor.aggregateProcessResourceHistory({
169 samples,
170 readAt: secondAt,
171 readAtMs: DateTime.toEpochMillis(secondAt),
172 windowMs: 60_000,
173 bucketMs: 10_000,
174 lastFailure: null,
175 });
176
177 expect(result.topProcesses).toHaveLength(1);
178 expect(result.topProcesses[0]?.isServerRoot).toBe(true);
179 expect(result.topProcesses[0]?.sampleCount).toBe(2);
180 expect(result.topProcesses[0]?.maxRssBytes).toBe(2_000);
181 }),
182 );
183
184 it.effect("returns all process summaries in the selected window", () =>
185 Effect.sync(() => {
186 const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
187 const samples = ProcessResourceMonitor.collectMonitoredSamples({
188 serverPid: 100,
189 sampledAt,
190 sampledAtMs: DateTime.toEpochMillis(sampledAt),
191 rows: [
19267 {
193 pid: 100,
68 identity: { pid: 5_000, startTimeMs: 200 },
19469 ppid: 1,
195 pgid: 100,
196 status: "S",
197 cpuPercent: 1,
198 rssBytes: 1_000,
199 elapsed: "01:00",
200 command: "t3 server",
70 depth: 0,
71 name: "electron",
72 command: "electron",
73 category: "electron-main",
74 firstSeenAt: DateTime.makeUnsafe("2026-05-05T09:59:55.000Z"),
75 lastSeenAt: readAt,
76 currentCpuPercent: 50,
77 avgCpuPercent: 40,
78 maxCpuPercent: 80,
79 cpuTimeMs: 15_000,
80 currentRssBytes: 20_480,
81 peakRssBytes: 40_960,
82 ioReadBytes: 10_240,
83 ioWriteBytes: 20_480,
84 ioSemantics: "storage",
85 sampleCount: 2,
20186 },
202 ...Array.from({ length: 35 }, (_, index) => ({
203 pid: 200 + index,
204 ppid: index === 0 ? 100 : 199 + index,
205 pgid: 100,
206 status: "S",
207 cpuPercent: 35 - index,
208 rssBytes: 2_000 + index,
209 elapsed: "00:10",
210 command: `worker ${index}`,
211 })),
21287 ],
213 });
214
215 const result = ProcessResourceMonitor.aggregateProcessResourceHistory({
216 samples,
217 readAt: sampledAt,
218 readAtMs: DateTime.toEpochMillis(sampledAt),
219 windowMs: 60_000,
220 bucketMs: 10_000,
221 lastFailure: null,
222 });
223
224 expect(result.topProcesses).toHaveLength(36);
225 expect(result.topProcesses.some((process) => process.command === "worker 34")).toBe(true);
226 }),
227 );
228
229 it.effect("exposes bounded failure diagnostics while retaining the exact cause", () =>
230 Effect.sync(() => {
231 const readAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
232 const cause = new Error("stderr included credential=secret-value");
233 const failure = new ProcessResourceMonitor.ProcessResourceSamplingError({
234 failureTag: "ProcessDiagnosticsQueryFailedError",
235 cause,
236 });
88 health: {
89 native: {
90 status: "degraded",
91 lastSampleAt: Option.some(readAt),
92 lastError: Option.some("collector stalled"),
93 },
94 desktop: {
95 status: "healthy",
96 lastSampleAt: Option.some(readAt),
97 lastError: Option.none(),
98 },
99 sidecarVersion: Option.some("0.1.0"),
100 sidecarPid: Option.some(9_000),
101 restartCount: 1,
102 collectionDurationMicros: 250,
103 scannedProcessCount: 80,
104 retainedProcessCount: 2,
105 inaccessibleProcessCount: 0,
106 },
107 };
108 const telemetry: ResourceTelemetry.ResourceTelemetry["Service"] = {
109 latest: Effect.die("unused"),
110 changes: Stream.empty,
111 subscribe: Effect.die("unused"),
112 readHistory: () => Effect.succeed(history),
113 refresh: Effect.die("unused"),
114 validateProcessIdentity: () => Effect.die("unused"),
115 retry: Effect.die("unused"),
116 };
117 const layer = ProcessResourceMonitor.layer.pipe(
118 Layer.provide(
119 Layer.succeed(
120 ResourceTelemetry.ResourceTelemetry,
121 ResourceTelemetry.ResourceTelemetry.of(telemetry),
122 ),
123 ),
124 );
237125
238 const result = ProcessResourceMonitor.aggregateProcessResourceHistory({
239 samples: [],
240 readAt,
241 readAtMs: DateTime.toEpochMillis(readAt),
242 windowMs: 60_000,
243 bucketMs: 10_000,
244 lastFailure: failure,
245 });
126 const result = yield* Effect.service(ProcessResourceMonitor.ProcessResourceMonitor).pipe(
127 Effect.flatMap((monitor) =>
128 monitor.readHistory({
129 windowMs: 60_000,
130 bucketMs: 10_000,
131 }),
132 ),
133 Effect.provide(layer),
134 );
246135
247 expect(failure.cause).toBe(cause);
248 expect(Option.getOrThrow(result.error)).toEqual({
249 failureTag: "ProcessDiagnosticsQueryFailedError",
250 message: "Failed to sample process resources (ProcessDiagnosticsQueryFailedError).",
136 expect(result.totalCpuSecondsApprox).toBe(1.5);
137 expect(result.topProcesses).toEqual([
138 {
139 processKey: `${process.pid}:100`,
140 pid: process.pid,
141 ppid: 1,
142 command: "t3 server",
143 depth: 0,
144 isServerRoot: true,
145 firstSeenAt: DateTime.makeUnsafe("2026-05-05T09:59:55.000Z"),
146 lastSeenAt: readAt,
147 currentCpuPercent: 5,
148 avgCpuPercent: 4,
149 maxCpuPercent: 8,
150 cpuSecondsApprox: 1.5,
151 currentRssBytes: 2_048,
152 maxRssBytes: 4_096,
153 sampleCount: 2,
154 },
155 ]);
156 expect(result.buckets[0]).toMatchObject({
157 avgCpuPercent: 5,
158 maxCpuPercent: 8,
159 maxRssBytes: 4_096,
160 maxProcessCount: 1,
251161 });
252 expect(Option.getOrThrow(result.error).message).not.toContain("secret-value");
162 expect(result.error).toEqual(
163 Option.some({
164 failureTag: "ProcessDiagnosticsQueryFailedError",
165 message: "collector stalled",
166 }),
167 );
253168 }),
254169 );
255170});
apps/server/src/diagnostics/ProcessResourceMonitor.ts+62-304
......@@ -1,55 +1,14 @@
1import {
2 ServerProcessResourceHistoryFailureTag,
3 type ServerProcessResourceHistoryBucket,
4 type ServerProcessResourceHistoryFailureTag as ServerProcessResourceHistoryFailureTagType,
5 type ServerProcessResourceHistoryInput,
6 type ServerProcessResourceHistoryResult,
7 type ServerProcessResourceHistorySummary,
1import type {
2 ResourceTelemetryProcessCategory,
3 ServerProcessResourceHistoryInput,
4 ServerProcessResourceHistoryResult,
85} from "@t3tools/contracts";
96import * as Context from "effect/Context";
10import * as DateTime from "effect/DateTime";
117import * as Effect from "effect/Effect";
128import * as Layer from "effect/Layer";
139import * as Option from "effect/Option";
14import * as Ref from "effect/Ref";
15import * as Schema from "effect/Schema";
16import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1710
18import * as ProcessDiagnostics from "./ProcessDiagnostics.ts";
19
20const SAMPLE_INTERVAL_MS = 5_000;
21const RETENTION_MS = 60 * 60_000;
22const MAX_RETAINED_SAMPLES = 20_000;
23
24export interface ProcessResourceSample {
25 readonly sampledAt: DateTime.Utc;
26 readonly sampledAtMs: number;
27 readonly processKey: string;
28 readonly pid: number;
29 readonly ppid: number;
30 readonly command: string;
31 readonly cpuPercent: number;
32 readonly rssBytes: number;
33 readonly depth: number;
34 readonly isServerRoot: boolean;
35}
36
37export class ProcessResourceSamplingError extends Schema.TaggedErrorClass<ProcessResourceSamplingError>()(
38 "ProcessResourceSamplingError",
39 {
40 failureTag: ServerProcessResourceHistoryFailureTag,
41 cause: Schema.Defect(),
42 },
43) {
44 override get message(): string {
45 return `Failed to sample process resources (${this.failureTag}).`;
46 }
47}
48
49interface MonitorState {
50 readonly samples: ReadonlyArray<ProcessResourceSample>;
51 readonly lastFailure: ProcessResourceSamplingError | null;
52}
11import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts";
5312
5413export class ProcessResourceMonitor extends Context.Service<
5514 ProcessResourceMonitor,
......@@ -60,270 +19,69 @@ export class ProcessResourceMonitor extends Context.Service<
6019 }
6120>()("t3/diagnostics/ProcessResourceMonitor") {}
6221
63function dateTimeFromMillis(ms: number): DateTime.Utc {
64 return DateTime.makeUnsafe(ms);
65}
66
67function sampleKey(row: Pick<ProcessDiagnostics.ProcessRow, "pid" | "command">): string {
68 return `${row.pid}:${row.command}`;
69}
70
71function findServerRootRow(
72 rows: ReadonlyArray<ProcessDiagnostics.ProcessRow>,
73 serverPid: number,
74): ProcessDiagnostics.ProcessRow | null {
75 return rows.find((row) => row.pid === serverPid) ?? null;
76}
77
78export function collectMonitoredSamples(input: {
79 readonly rows: ReadonlyArray<ProcessDiagnostics.ProcessRow>;
80 readonly serverPid: number;
81 readonly sampledAt: DateTime.Utc;
82 readonly sampledAtMs: number;
83}): ReadonlyArray<ProcessResourceSample> {
84 const rows = input.rows.filter(
85 (row) => !ProcessDiagnostics.isDiagnosticsQueryProcess(row, input.serverPid),
22function isLegacyBackendCategory(category: ResourceTelemetryProcessCategory): boolean {
23 return (
24 category === "server" ||
25 category === "server-child" ||
26 category === "provider-root" ||
27 category === "terminal-root"
8628 );
87 const root = findServerRootRow(rows, input.serverPid);
88 const descendants = ProcessDiagnostics.buildDescendantEntries(rows, input.serverPid);
89 const samples: ProcessResourceSample[] = [];
90
91 if (root) {
92 samples.push({
93 sampledAt: input.sampledAt,
94 sampledAtMs: input.sampledAtMs,
95 processKey: sampleKey(root),
96 pid: root.pid,
97 ppid: root.ppid,
98 command: root.command,
99 cpuPercent: root.cpuPercent,
100 rssBytes: root.rssBytes,
101 depth: 0,
102 isServerRoot: true,
103 });
104 }
105
106 for (const process of descendants) {
107 samples.push({
108 sampledAt: input.sampledAt,
109 sampledAtMs: input.sampledAtMs,
110 processKey: sampleKey(process),
111 pid: process.pid,
112 ppid: process.ppid,
113 command: process.command,
114 cpuPercent: process.cpuPercent,
115 rssBytes: process.rssBytes,
116 depth: process.depth + 1,
117 isServerRoot: false,
118 });
119 }
120
121 return samples;
122}
123
124function trimSamples(
125 samples: ReadonlyArray<ProcessResourceSample>,
126 nowMs: number,
127): ReadonlyArray<ProcessResourceSample> {
128 const minSampledAtMs = nowMs - RETENTION_MS;
129 const retained = samples.filter((sample) => sample.sampledAtMs >= minSampledAtMs);
130 return retained.length <= MAX_RETAINED_SAMPLES
131 ? retained
132 : retained.slice(retained.length - MAX_RETAINED_SAMPLES);
13329}
13430
135function summarizeProcesses(
136 samples: ReadonlyArray<ProcessResourceSample>,
137): ReadonlyArray<ServerProcessResourceHistorySummary> {
138 const groups = new Map<string, ProcessResourceSample[]>();
139 for (const sample of samples) {
140 const processSamples = groups.get(sample.processKey) ?? [];
141 processSamples.push(sample);
142 groups.set(sample.processKey, processSamples);
143 }
144
145 return [...groups.entries()]
146 .map(([processKey, processSamples]) => {
147 const sorted = processSamples.toSorted((left, right) => left.sampledAtMs - right.sampledAtMs);
148 const first = sorted[0]!;
149 const latest = sorted[sorted.length - 1]!;
150 const cpuPercentTotal = sorted.reduce((total, sample) => total + sample.cpuPercent, 0);
151 const maxCpuPercent = Math.max(...sorted.map((sample) => sample.cpuPercent));
152 const maxRssBytes = Math.max(...sorted.map((sample) => sample.rssBytes));
153 const cpuSecondsApprox = sorted.reduce(
154 (total, sample) => total + (sample.cpuPercent / 100) * (SAMPLE_INTERVAL_MS / 1_000),
155 0,
156 );
157
158 return {
159 processKey,
160 pid: latest.pid,
161 ppid: latest.ppid,
162 command: latest.command,
163 depth: latest.depth,
164 isServerRoot: latest.isServerRoot,
165 firstSeenAt: first.sampledAt,
166 lastSeenAt: latest.sampledAt,
167 currentCpuPercent: latest.cpuPercent,
168 avgCpuPercent: cpuPercentTotal / sorted.length,
169 maxCpuPercent,
170 cpuSecondsApprox,
171 currentRssBytes: latest.rssBytes,
172 maxRssBytes,
173 sampleCount: sorted.length,
174 } satisfies ServerProcessResourceHistorySummary;
175 })
176 .toSorted((left, right) => right.cpuSecondsApprox - left.cpuSecondsApprox);
177}
178
179function buildBuckets(input: {
180 readonly samples: ReadonlyArray<ProcessResourceSample>;
181 readonly nowMs: number;
182 readonly windowMs: number;
183 readonly bucketMs: number;
184}): ReadonlyArray<ServerProcessResourceHistoryBucket> {
185 const bucketMs = Math.max(1_000, input.bucketMs);
186 const windowStartMs = input.nowMs - input.windowMs;
187 const buckets: ServerProcessResourceHistoryBucket[] = [];
188
189 for (let startedAtMs = windowStartMs; startedAtMs < input.nowMs; startedAtMs += bucketMs) {
190 const endedAtMs = Math.min(input.nowMs, startedAtMs + bucketMs);
191 const bucketSamples = input.samples.filter(
192 (sample) =>
193 sample.sampledAtMs >= startedAtMs &&
194 (endedAtMs === input.nowMs
195 ? sample.sampledAtMs <= endedAtMs
196 : sample.sampledAtMs < endedAtMs),
197 );
198 const samplesByRead = new Map<number, ProcessResourceSample[]>();
199 for (const sample of bucketSamples) {
200 const samplesAtTime = samplesByRead.get(sample.sampledAtMs) ?? [];
201 samplesAtTime.push(sample);
202 samplesByRead.set(sample.sampledAtMs, samplesAtTime);
203 }
204
205 const readTotals = [...samplesByRead.values()].map((samplesAtTime) => ({
206 cpuPercent: samplesAtTime.reduce((total, sample) => total + sample.cpuPercent, 0),
207 rssBytes: samplesAtTime.reduce((total, sample) => total + sample.rssBytes, 0),
208 processCount: samplesAtTime.length,
209 }));
210 const avgCpuPercent =
211 readTotals.length === 0
212 ? 0
213 : readTotals.reduce((total, read) => total + read.cpuPercent, 0) / readTotals.length;
214
215 buckets.push({
216 startedAt: dateTimeFromMillis(startedAtMs),
217 endedAt: dateTimeFromMillis(endedAtMs),
218 avgCpuPercent,
219 maxCpuPercent: readTotals.length ? Math.max(...readTotals.map((read) => read.cpuPercent)) : 0,
220 maxRssBytes: readTotals.length ? Math.max(...readTotals.map((read) => read.rssBytes)) : 0,
221 maxProcessCount: readTotals.length
222 ? Math.max(...readTotals.map((read) => read.processCount))
223 : 0,
224 });
225 }
226
227 return buckets;
228}
229
230export function aggregateProcessResourceHistory(input: {
231 readonly samples: ReadonlyArray<ProcessResourceSample>;
232 readonly readAt: DateTime.Utc;
233 readonly readAtMs: number;
234 readonly windowMs: number;
235 readonly bucketMs: number;
236 readonly lastFailure: ProcessResourceSamplingError | null;
237}): ServerProcessResourceHistoryResult {
238 const windowMs = Math.max(1_000, input.windowMs);
239 const bucketMs = Math.max(1_000, input.bucketMs);
240 const minSampledAtMs = input.readAtMs - windowMs;
241 const samples = input.samples.filter((sample) => sample.sampledAtMs >= minSampledAtMs);
242 const topProcesses = summarizeProcesses(samples);
243 const totalCpuSecondsApprox = samples.reduce(
244 (total, sample) => total + (sample.cpuPercent / 100) * (SAMPLE_INTERVAL_MS / 1_000),
245 0,
246 );
247
248 return {
249 readAt: input.readAt,
250 windowMs,
251 bucketMs,
252 sampleIntervalMs: SAMPLE_INTERVAL_MS,
253 retainedSampleCount: input.samples.length,
254 totalCpuSecondsApprox,
255 buckets: buildBuckets({ samples, nowMs: input.readAtMs, windowMs, bucketMs }),
256 topProcesses,
257 error: input.lastFailure
258 ? Option.some({
259 failureTag: input.lastFailure.failureTag,
260 message: input.lastFailure.message,
261 })
262 : Option.none(),
263 };
264}
265
266export const make = Effect.gen(function* () {
267 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
268 const state = yield* Ref.make<MonitorState>({ samples: [], lastFailure: null });
269
270 const recordSamplingFailure = (cause: {
271 readonly _tag: ServerProcessResourceHistoryFailureTagType;
272 }) =>
273 Ref.update(state, (current) => ({
274 ...current,
275 lastFailure: new ProcessResourceSamplingError({
276 failureTag: cause._tag,
277 cause,
31export const make = Effect.fn("makeProcessResourceMonitor")(function* () {
32 const telemetry = yield* ResourceTelemetry.ResourceTelemetry;
33 const readHistory: ProcessResourceMonitor["Service"]["readHistory"] = (input) =>
34 telemetry.readHistory(input).pipe(
35 Effect.map((history) => {
36 const topProcesses = history.topProcesses
37 .filter((entry) => isLegacyBackendCategory(entry.category))
38 .map((entry) => ({
39 processKey: `${entry.identity.pid}:${entry.identity.startTimeMs}`,
40 pid: entry.identity.pid,
41 ppid: entry.ppid,
42 command: entry.command || entry.name || "unknown",
43 depth: entry.depth,
44 isServerRoot: entry.category === "server",
45 firstSeenAt: entry.firstSeenAt,
46 lastSeenAt: entry.lastSeenAt,
47 currentCpuPercent: entry.currentCpuPercent,
48 avgCpuPercent: entry.avgCpuPercent,
49 maxCpuPercent: entry.maxCpuPercent,
50 cpuSecondsApprox: entry.cpuTimeMs / 1_000,
51 currentRssBytes: entry.currentRssBytes,
52 maxRssBytes: entry.peakRssBytes,
53 sampleCount: entry.sampleCount,
54 }));
55 return {
56 readAt: history.readAt,
57 windowMs: history.windowMs,
58 bucketMs: history.bucketMs,
59 sampleIntervalMs: history.sampleIntervalMs,
60 retainedSampleCount: history.retainedSampleCount,
61 totalCpuSecondsApprox: topProcesses.reduce(
62 (total, entry) => total + entry.cpuSecondsApprox,
63 0,
64 ),
65 buckets: (history.legacyBackendBuckets ?? history.buckets).map((bucket) => ({
66 startedAt: bucket.startedAt,
67 endedAt: bucket.endedAt,
68 avgCpuPercent: bucket.avgCpuPercent,
69 maxCpuPercent: bucket.maxCpuPercent,
70 maxRssBytes: bucket.maxRssBytes,
71 maxProcessCount: bucket.maxProcessCount,
72 })),
73 topProcesses,
74 error: history.health.native.lastError.pipe(
75 Option.map((message) => ({
76 failureTag: "ProcessDiagnosticsQueryFailedError" as const,
77 message,
78 })),
79 ),
80 };
27881 }),
279 }));
280
281 const sampleOnce = Effect.gen(function* () {
282 const sampledAt = yield* DateTime.now;
283 const sampledAtMs = DateTime.toEpochMillis(sampledAt);
284 const rows = yield* ProcessDiagnostics.readProcessRows.pipe(
285 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
28682 );
287 const samples = collectMonitoredSamples({
288 rows,
289 serverPid: process.pid,
290 sampledAt,
291 sampledAtMs,
292 });
293 yield* Ref.update(state, (current) => ({
294 samples: trimSamples([...current.samples, ...samples], sampledAtMs),
295 lastFailure: null,
296 }));
297 }).pipe(
298 Effect.catchTags({
299 ProcessDiagnosticsQueryTimeoutError: recordSamplingFailure,
300 ProcessDiagnosticsQueryFailedError: recordSamplingFailure,
301 ProcessDiagnosticsServerProcessSignalError: recordSamplingFailure,
302 ProcessDiagnosticsNotDescendantError: recordSamplingFailure,
303 ProcessDiagnosticsSignalFailedError: recordSamplingFailure,
304 }),
305 );
306
307 yield* Effect.forever(sampleOnce.pipe(Effect.andThen(Effect.sleep(SAMPLE_INTERVAL_MS)))).pipe(
308 Effect.forkScoped,
309 );
310
311 const readHistory: ProcessResourceMonitor["Service"]["readHistory"] = (input) =>
312 Effect.gen(function* () {
313 const readAt = yield* DateTime.now;
314 const readAtMs = DateTime.toEpochMillis(readAt);
315 const current = yield* Ref.get(state);
316 return aggregateProcessResourceHistory({
317 samples: current.samples,
318 readAt,
319 readAtMs,
320 windowMs: input.windowMs,
321 bucketMs: input.bucketMs,
322 lastFailure: current.lastFailure,
323 });
324 });
32583
32684 return ProcessResourceMonitor.of({ readHistory });
32785});
32886
329export const layer = Layer.effect(ProcessResourceMonitor, make);
87export const layer = Layer.effect(ProcessResourceMonitor, make());
apps/server/src/observability/Layers/Observability.ts+10
......@@ -10,6 +10,7 @@ import * as OtlpSerialization from "effect/unstable/observability/OtlpSerializat
1010import * as OtlpTracer from "effect/unstable/observability/OtlpTracer";
1111
1212import * as ServerConfig from "../../config.ts";
13import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts";
1314import { ServerLoggerLive } from "../../serverLogger.ts";
1415import * as BrowserTraceCollector from "../BrowserTraceCollector.ts";
1516
......@@ -18,6 +19,7 @@ const otlpSerializationLayer = OtlpSerialization.layerJson;
1819export const ObservabilityLive = Layer.unwrap(
1920 Effect.gen(function* () {
2021 const config = yield* ServerConfig.ServerConfig;
22 const attribution = yield* ResourceAttribution.ResourceAttribution;
2123
2224 const traceReferencesLayer = Layer.mergeAll(
2325 Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel),
......@@ -32,6 +34,14 @@ export const ObservabilityLive = Layer.unwrap(
3234 maxBytes: config.traceMaxBytes,
3335 maxFiles: config.traceMaxFiles,
3436 batchWindowMs: config.traceBatchWindowMs,
37 onFlush: (stats) =>
38 attribution.record({
39 component: "server-trace",
40 operation: "append",
41 logicalWriteBytes: stats.logicalWriteBytes,
42 count: stats.count,
43 durationMs: stats.durationMs,
44 }),
3545 });
3646 const delegate =
3747 config.otlpTracesUrl === undefined
apps/server/src/provider/Drivers/ClaudeDriver.ts+2-2
......@@ -24,6 +24,7 @@ import { HttpClient } from "effect/unstable/http";
2424import { ChildProcessSpawner } from "effect/unstable/process";
2525
2626import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts";
27import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
2728import { ServerConfig } from "../../config.ts";
2829import { ServerSettingsService } from "../../serverSettings.ts";
2930import { ProviderDriverError } from "../Errors.ts";
......@@ -57,7 +58,6 @@ import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "
5758const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
5859
5960const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
60const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
6161const CAPABILITIES_PROBE_TTL = Duration.minutes(5);
6262
6363function isClaudeNativeCommandPath(commandPath: string): boolean {
......@@ -82,6 +82,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({
8282});
8383
8484export type ClaudeDriverEnv =
85 | BackgroundPolicy.BackgroundPolicy
8586 | ChildProcessSpawner.ChildProcessSpawner
8687 | Crypto.Crypto
8788 | FileSystem.FileSystem
......@@ -190,7 +191,6 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
190191 Effect.provideService(HttpClient.HttpClient, httpClient),
191192 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
192193 ),
193 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
194194 }).pipe(
195195 Effect.mapError(
196196 (cause) =>
apps/server/src/provider/Drivers/CodexDriver.ts+2-3
......@@ -22,7 +22,6 @@
2222 * @module provider/Drivers/CodexDriver
2323 */
2424import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
25import * as Duration from "effect/Duration";
2625import * as Crypto from "effect/Crypto";
2726import * as Effect from "effect/Effect";
2827import * as FileSystem from "effect/FileSystem";
......@@ -32,6 +31,7 @@ import { HttpClient } from "effect/unstable/http";
3231import { ChildProcessSpawner } from "effect/unstable/process";
3332
3433import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts";
34import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
3535import { ServerConfig } from "../../config.ts";
3636import { ServerSettingsService } from "../../serverSettings.ts";
3737import { ProviderDriverError } from "../Errors.ts";
......@@ -60,7 +60,6 @@ import {
6060const decodeCodexSettings = Schema.decodeSync(CodexSettings);
6161
6262const DRIVER_KIND = ProviderDriverKind.make("codex");
63const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
6463const UPDATE = makePackageManagedProviderMaintenanceResolver({
6564 provider: DRIVER_KIND,
6665 npmPackageName: "@openai/codex",
......@@ -74,6 +73,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({
7473 * registered driver and the runtime satisfies them once.
7574 */
7675export type CodexDriverEnv =
76 | BackgroundPolicy.BackgroundPolicy
7777 | ChildProcessSpawner.ChildProcessSpawner
7878 | Crypto.Crypto
7979 | FileSystem.FileSystem
......@@ -186,7 +186,6 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
186186 Effect.provideService(HttpClient.HttpClient, httpClient),
187187 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
188188 ),
189 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
190189 }).pipe(
191190 Effect.mapError(
192191 (cause) =>
apps/server/src/provider/Drivers/CursorDriver.ts+3-4
......@@ -12,7 +12,6 @@
1212 * @module provider/Drivers/CursorDriver
1313 */
1414import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
15import * as Duration from "effect/Duration";
1615import * as Crypto from "effect/Crypto";
1716import * as Effect from "effect/Effect";
1817import * as FileSystem from "effect/FileSystem";
......@@ -21,6 +20,7 @@ import * as Schema from "effect/Schema";
2120import { HttpClient } from "effect/unstable/http";
2221import { ChildProcessSpawner } from "effect/unstable/process";
2322
23import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
2424import { ServerConfig } from "../../config.ts";
2525import { ServerSettingsService } from "../../serverSettings.ts";
2626import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts";
......@@ -53,7 +53,6 @@ import {
5353const decodeCursorSettings = Schema.decodeSync(CursorSettings);
5454
5555const DRIVER_KIND = ProviderDriverKind.make("cursor");
56const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
5756const UPDATE: ProviderMaintenanceCapabilitiesResolver = {
5857 resolve: (options) =>
5958 makeProviderMaintenanceCapabilities({
......@@ -66,6 +65,7 @@ const UPDATE: ProviderMaintenanceCapabilitiesResolver = {
6665};
6766
6867export type CursorDriverEnv =
68 | BackgroundPolicy.BackgroundPolicy
6969 | ChildProcessSpawner.ChildProcessSpawner
7070 | Crypto.Crypto
7171 | FileSystem.FileSystem
......@@ -160,8 +160,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
160160 publishSnapshot,
161161 stampIdentity,
162162 httpClient,
163 }),
164 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
163 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)),
165164 }).pipe(
166165 Effect.mapError(
167166 (cause) =>
apps/server/src/provider/Drivers/GrokDriver.ts+2-3
......@@ -1,5 +1,4 @@
11import { GrokSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
2import * as Duration from "effect/Duration";
32import * as Crypto from "effect/Crypto";
43import * as Effect from "effect/Effect";
54import * as FileSystem from "effect/FileSystem";
......@@ -8,6 +7,7 @@ import * as Schema from "effect/Schema";
87import { HttpClient } from "effect/unstable/http";
98import { ChildProcessSpawner } from "effect/unstable/process";
109
10import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
1111import { ServerConfig } from "../../config.ts";
1212import { ServerSettingsService } from "../../serverSettings.ts";
1313import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts";
......@@ -40,7 +40,6 @@ import {
4040const decodeGrokSettings = Schema.decodeSync(GrokSettings);
4141
4242const DRIVER_KIND = ProviderDriverKind.make("grok");
43const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
4443const UPDATE = makeStaticProviderMaintenanceResolver(
4544 makeManualOnlyProviderMaintenanceCapabilities({
4645 provider: DRIVER_KIND,
......@@ -49,6 +48,7 @@ const UPDATE = makeStaticProviderMaintenanceResolver(
4948);
5049
5150export type GrokDriverEnv =
51 | BackgroundPolicy.BackgroundPolicy
5252 | ChildProcessSpawner.ChildProcessSpawner
5353 | Crypto.Crypto
5454 | FileSystem.FileSystem
......@@ -136,7 +136,6 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
136136 publishSnapshot,
137137 httpClient,
138138 }),
139 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
140139 }).pipe(
141140 Effect.mapError(
142141 (cause) =>
apps/server/src/provider/Drivers/OpenCodeDriver.ts+2-3
......@@ -13,7 +13,6 @@
1313 * @module provider/Drivers/OpenCodeDriver
1414 */
1515import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
16import * as Duration from "effect/Duration";
1716import * as Crypto from "effect/Crypto";
1817import * as Effect from "effect/Effect";
1918import * as FileSystem from "effect/FileSystem";
......@@ -23,6 +22,7 @@ import { HttpClient } from "effect/unstable/http";
2322import { ChildProcessSpawner } from "effect/unstable/process";
2423
2524import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts";
25import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
2626import { ServerConfig } from "../../config.ts";
2727import { ServerSettingsService } from "../../serverSettings.ts";
2828import { ProviderDriverError } from "../Errors.ts";
......@@ -55,7 +55,6 @@ import {
5555const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings);
5656
5757const DRIVER_KIND = ProviderDriverKind.make("opencode");
58const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
5958
6059function isOpenCodeNativeCommandPath(commandPath: string): boolean {
6160 const normalized = normalizeCommandPath(commandPath);
......@@ -78,6 +77,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({
7877});
7978
8079export type OpenCodeDriverEnv =
80 | BackgroundPolicy.BackgroundPolicy
8181 | ChildProcessSpawner.ChildProcessSpawner
8282 | Crypto.Crypto
8383 | FileSystem.FileSystem
......@@ -166,7 +166,6 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
166166 Effect.provideService(HttpClient.HttpClient, httpClient),
167167 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
168168 ),
169 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
170169 },
171170 ).pipe(
172171 Effect.mapError(
apps/server/src/provider/Layers/ClaudeAdapter.ts+4-1
......@@ -1354,6 +1354,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
13541354 stream: "native",
13551355 })
13561356 : undefined);
1357 const managedNativeEventLogger =
1358 options?.nativeEventLogger === undefined ? nativeEventLogger : undefined;
13571359
13581360 const createQuery =
13591361 options?.createQuery ??
......@@ -1387,7 +1389,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
13871389 const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
13881390 Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid);
13891391
1390 const logNativeSdkMessage = Effect.fn("logNativeSdkMessage")(function* (
1392 const logNativeSdkMessage = Effect.fnUntraced(function* (
13911393 context: ClaudeSessionContext,
13921394 message: SDKMessage,
13931395 ) {
......@@ -3922,6 +3924,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
39223924 Effect.logError("Failed to emit Claude session shutdown event.", { cause }),
39233925 ),
39243926 Effect.tap(() => Queue.shutdown(runtimeEventQueue)),
3927 Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void),
39253928 ),
39263929 );
39273930
apps/server/src/provider/Layers/CodexAdapter.test.ts+1-1
......@@ -1291,7 +1291,7 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () =>
12911291 yield* Scope.close(scope, Exit.void);
12921292 scopeClosed = true;
12931293
1294 const threadLogPath = NodePath.join(tempDir, "thread-logger.log");
1294 const threadLogPath = NodePath.join(tempDir, "provider-native.thread-logger.log");
12951295 NodeAssert.equal(NodeFS.existsSync(threadLogPath), true);
12961296 const contents = NodeFS.readFileSync(threadLogPath, "utf8");
12971297 NodeAssert.match(contents, /NTIVE: .*"message":"native flush test"/);
apps/server/src/provider/Layers/CodexAdapter.ts+1-1
......@@ -1646,7 +1646,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
16461646 ),
16471647 );
16481648
1649 const writeNativeEvent = Effect.fn("writeNativeEvent")(function* (event: ProviderEvent) {
1649 const writeNativeEvent = Effect.fnUntraced(function* (event: ProviderEvent) {
16501650 if (!nativeEventLogger) {
16511651 return;
16521652 }
apps/server/src/provider/Layers/EventNdjsonLogger.test.ts+361-15
......@@ -5,14 +5,30 @@ import * as NodePath from "node:path";
55
66import { ThreadId } from "@t3tools/contracts";
77import { assert, describe, it } from "@effect/vitest";
8import * as Clock from "effect/Clock";
89import * as Effect from "effect/Effect";
10import * as Fiber from "effect/Fiber";
911import * as Logger from "effect/Logger";
1012import * as Schema from "effect/Schema";
13import * as TestClock from "effect/testing/TestClock";
1114
12import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
15import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts";
16import {
17 makeEventNdjsonLogger,
18 makeEventNdjsonLogStore,
19 type PendingRecord,
20 writeBatchedMessages,
21} from "./EventNdjsonLogger.ts";
1322
1423const encodeUnknownJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString);
1524
25function ownedLogPath(basePath: string, segment: string): string {
26 const basename = NodePath.basename(basePath);
27 const extension = NodePath.extname(basename);
28 const stem = extension.length > 0 ? basename.slice(0, -extension.length) : basename;
29 return NodePath.join(NodePath.dirname(basePath), `${stem}.${segment}.log`);
30}
31
1632function parseLogLine(line: string) {
1733 const match = /^\[([^\]]+)\] ([A-Z]+): (.+)$/.exec(line);
1834 assert.notEqual(match, null);
......@@ -87,8 +103,8 @@ describe("EventNdjsonLogger", () => {
87103 );
88104 yield* logger.close();
89105
90 const threadOnePath = NodePath.join(tempDir, "thread-1.log");
91 const threadTwoPath = NodePath.join(tempDir, "thread-2.log");
106 const threadOnePath = ownedLogPath(basePath, "thread-1");
107 const threadTwoPath = ownedLogPath(basePath, "thread-2");
92108 assert.equal(NodeFS.existsSync(threadOnePath), true);
93109 assert.equal(NodeFS.existsSync(threadTwoPath), true);
94110
......@@ -129,7 +145,7 @@ describe("EventNdjsonLogger", () => {
129145 yield* logger.write({ id: "evt-invalid-thread" }, "!!!" as unknown as ThreadId);
130146 yield* logger.close();
131147
132 const globalPath = NodePath.join(tempDir, "_global.log");
148 const globalPath = ownedLogPath(basePath, "_global");
133149 assert.equal(NodeFS.existsSync(globalPath), true);
134150 const lines = NodeFS.readFileSync(globalPath, "utf8")
135151 .trim()
......@@ -138,9 +154,9 @@ describe("EventNdjsonLogger", () => {
138154 assert.equal(lines.length, 2);
139155 assert.equal(Number.isNaN(Date.parse(lines[0]?.observedAt ?? "")), false);
140156 assert.equal(Number.isNaN(Date.parse(lines[1]?.observedAt ?? "")), false);
141 assert.equal(lines[0]?.stream, "CANON");
157 assert.equal(lines[0]?.stream, "ORCH");
142158 assert.equal(lines[0]?.payload, '{"id":"evt-no-thread"}');
143 assert.equal(lines[1]?.stream, "CANON");
159 assert.equal(lines[1]?.stream, "ORCH");
144160 assert.equal(lines[1]?.payload, '{"id":"evt-invalid-thread"}');
145161 } finally {
146162 NodeFS.rmSync(tempDir, { recursive: true, force: true });
......@@ -148,6 +164,197 @@ describe("EventNdjsonLogger", () => {
148164 }),
149165 );
150166
167 it.effect("shares one thread writer across native and canonical streams", () =>
168 Effect.gen(function* () {
169 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
170 const basePath = NodePath.join(tempDir, "events.log");
171
172 try {
173 const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 });
174 const native = store.logger("native");
175 const canonical = store.logger("canonical");
176 const threadId = ThreadId.make("thread-shared");
177
178 yield* native.write({ id: "native-event" }, threadId);
179 yield* canonical.write({ type: "item.completed", id: "canonical-event" }, threadId);
180 yield* store.close();
181
182 const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-shared"), "utf8")
183 .trim()
184 .split("\n")
185 .map(parseLogLine);
186
187 assert.deepEqual(
188 lines.map(({ stream, payload }) => ({ stream, payload })),
189 [
190 { stream: "NTIVE", payload: '{"id":"native-event"}' },
191 {
192 stream: "CANON",
193 payload: '{"type":"item.completed","id":"canonical-event"}',
194 },
195 ],
196 );
197 } finally {
198 NodeFS.rmSync(tempDir, { recursive: true, force: true });
199 }
200 }),
201 );
202
203 it.effect("keeps shared store views non-owning when one adapter closes", () =>
204 Effect.gen(function* () {
205 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
206 const basePath = NodePath.join(tempDir, "events.log");
207
208 try {
209 const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 });
210 const native = store.logger("native");
211 const canonical = store.logger("canonical");
212 const threadId = ThreadId.make("thread-shared-close");
213
214 yield* native.write({ id: "before-close" }, threadId);
215 yield* native.close();
216 yield* canonical.write({ type: "item.completed", id: "after-close" }, threadId);
217 yield* store.close();
218
219 const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-shared-close"), "utf8")
220 .trim()
221 .split("\n")
222 .map(parseLogLine);
223
224 assert.deepEqual(
225 lines.map(({ stream, payload }) => ({ stream, payload })),
226 [
227 { stream: "NTIVE", payload: '{"id":"before-close"}' },
228 {
229 stream: "CANON",
230 payload: '{"type":"item.completed","id":"after-close"}',
231 },
232 ],
233 );
234 } finally {
235 NodeFS.rmSync(tempDir, { recursive: true, force: true });
236 }
237 }),
238 );
239
240 it.effect("flushes an active batch without a permanent polling loop", () =>
241 Effect.gen(function* () {
242 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
243 const basePath = NodePath.join(tempDir, "events.log");
244 const threadPath = ownedLogPath(basePath, "thread-batched");
245
246 try {
247 const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 1_000 });
248 yield* store
249 .logger("native")
250 .write({ id: "batched-event" }, ThreadId.make("thread-batched"));
251
252 assert.equal(NodeFS.existsSync(threadPath), false);
253 yield* TestClock.adjust(1_000);
254 assert.equal(NodeFS.existsSync(threadPath), true);
255 yield* store.close();
256 } finally {
257 NodeFS.rmSync(tempDir, { recursive: true, force: true });
258 }
259 }),
260 );
261
262 it.effect("does not strand a later batch after an interrupted write", () =>
263 Effect.gen(function* () {
264 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
265 const basePath = NodePath.join(tempDir, "events.log");
266 const threadPath = ownedLogPath(basePath, "thread-interrupted");
267
268 try {
269 const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 1_000 });
270 const logger = store.logger("native");
271 const interruptedWrite = yield* logger
272 .write({ id: "possibly-interrupted" }, ThreadId.make("thread-interrupted"))
273 .pipe(Effect.forkChild);
274 yield* Effect.yieldNow;
275 yield* Fiber.interrupt(interruptedWrite);
276 yield* logger.write({ id: "accepted" }, ThreadId.make("thread-interrupted"));
277
278 yield* TestClock.adjust(1_000);
279
280 assert.equal(NodeFS.existsSync(threadPath), true);
281 assert.include(NodeFS.readFileSync(threadPath, "utf8"), '{"id":"accepted"}');
282 yield* store.close();
283 } finally {
284 NodeFS.rmSync(tempDir, { recursive: true, force: true });
285 }
286 }),
287 );
288
289 it.effect("drops transient canonical events before serialization", () =>
290 Effect.gen(function* () {
291 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
292 const basePath = NodePath.join(tempDir, "events.log");
293
294 try {
295 const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 });
296 const canonical = store.logger("canonical");
297 const native = store.logger("native");
298 const threadId = ThreadId.make("thread-filtered");
299 const circularDelta: Record<string, unknown> = { type: "content.delta" };
300 circularDelta["self"] = circularDelta;
301
302 yield* canonical.write(circularDelta, threadId);
303 yield* canonical.write({ type: "item.completed", id: "final" }, threadId);
304 yield* native.write({ type: "content.delta", id: "native-delta" }, threadId);
305 yield* store.close();
306
307 const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-filtered"), "utf8")
308 .trim()
309 .split("\n")
310 .map(parseLogLine);
311
312 assert.deepEqual(
313 lines.map(({ stream, payload }) => ({ stream, payload })),
314 [
315 { stream: "CANON", payload: '{"type":"item.completed","id":"final"}' },
316 { stream: "NTIVE", payload: '{"type":"content.delta","id":"native-delta"}' },
317 ],
318 );
319 } finally {
320 NodeFS.rmSync(tempDir, { recursive: true, force: true });
321 }
322 }),
323 );
324
325 it.effect("contains hostile event accessors inside guarded serialization", () =>
326 Effect.gen(function* () {
327 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
328 const basePath = NodePath.join(tempDir, "events.log");
329
330 try {
331 const logger = yield* makeEventNdjsonLogger(basePath, {
332 stream: "canonical",
333 batchWindowMs: 0,
334 });
335 assert.exists(logger);
336 if (!logger) return;
337 const hostile = new Proxy(
338 { id: "hostile" },
339 {
340 get(_target, property) {
341 if (property === "type") throw new Error("blocked");
342 return undefined;
343 },
344 },
345 );
346
347 yield* logger.write(hostile, ThreadId.make("thread-hostile"));
348 yield* logger.close();
349
350 const contents = NodeFS.readFileSync(ownedLogPath(basePath, "thread-hostile"), "utf8");
351 assert.notInclude(contents, "blocked");
352 } finally {
353 NodeFS.rmSync(tempDir, { recursive: true, force: true });
354 }
355 }),
356 );
357
151358 it.effect("serializes concurrent first writes for the same segment", () =>
152359 Effect.gen(function* () {
153360 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
......@@ -172,7 +379,7 @@ describe("EventNdjsonLogger", () => {
172379 );
173380 yield* logger.close();
174381
175 const globalPath = NodePath.join(tempDir, "_global.log");
382 const globalPath = ownedLogPath(basePath, "_global");
176383 assert.equal(NodeFS.existsSync(globalPath), true);
177384 const lines = NodeFS.readFileSync(globalPath, "utf8")
178385 .trim()
......@@ -196,19 +403,19 @@ describe("EventNdjsonLogger", () => {
196403 const basePath = NodePath.join(tempDir, "provider-native.ndjson");
197404
198405 try {
199 const logger = yield* makeEventNdjsonLogger(basePath, {
200 stream: "native",
406 const store = yield* makeEventNdjsonLogStore(basePath, {
201407 maxBytes: 120,
202408 maxFiles: 2,
409 batchWindowMs: 0,
203410 });
204 assert.notEqual(logger, undefined);
205 if (!logger) {
206 return;
207 }
411 const native = store.logger("native");
412 const canonical = store.logger("canonical");
208413
209414 for (let index = 0; index < 10; index += 1) {
415 const logger = index % 2 === 0 ? native : canonical;
210416 yield* logger.write(
211417 {
418 type: "session.started",
212419 threadId: "provider-thread-rotate",
213420 id: `evt-${index}`,
214421 payload: "x".repeat(40),
......@@ -216,9 +423,9 @@ describe("EventNdjsonLogger", () => {
216423 ThreadId.make("thread-rotate"),
217424 );
218425 }
219 yield* logger.close();
426 yield* store.close();
220427
221 const fileStem = "thread-rotate.log";
428 const fileStem = NodePath.basename(ownedLogPath(basePath, "thread-rotate"));
222429 const matchingFiles = NodeFS.readdirSync(tempDir)
223430 .filter((entry) => entry === fileStem || entry.startsWith(`${fileStem}.`))
224431 .toSorted();
......@@ -240,4 +447,143 @@ describe("EventNdjsonLogger", () => {
240447 }
241448 }),
242449 );
450
451 it.effect("enforces aggregate age and byte retention on startup", () =>
452 Effect.gen(function* () {
453 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
454 const basePath = NodePath.join(tempDir, "events.log");
455 const expiredPath = ownedLogPath(basePath, "expired");
456 const oldPath = ownedLogPath(basePath, "old");
457 const newPath = ownedLogPath(basePath, "new");
458 const unrelatedLogPath = NodePath.join(tempDir, "unrelated.log");
459 const legacyLogPath = NodePath.join(tempDir, "legacy-thread.log");
460 const ignoredPath = NodePath.join(tempDir, "ignored.txt");
461
462 try {
463 yield* TestClock.setTime(1_800_000_000_000);
464 const now = yield* Clock.currentTimeMillis;
465 for (const filePath of [expiredPath, oldPath, newPath, unrelatedLogPath, ignoredPath]) {
466 NodeFS.writeFileSync(filePath, "x".repeat(40));
467 }
468 NodeFS.writeFileSync(
469 legacyLogPath,
470 "[2026-01-01T00:00:00.000Z] CANON: legacy provider event\n",
471 );
472 NodeFS.utimesSync(expiredPath, (now - 20_000) / 1_000, (now - 20_000) / 1_000);
473 NodeFS.utimesSync(legacyLogPath, (now - 20_000) / 1_000, (now - 20_000) / 1_000);
474 NodeFS.utimesSync(oldPath, (now - 5_000) / 1_000, (now - 5_000) / 1_000);
475 NodeFS.utimesSync(newPath, now / 1_000, now / 1_000);
476
477 const store = yield* makeEventNdjsonLogStore(basePath, {
478 maxAgeMs: 10_000,
479 maxTotalBytes: 60,
480 });
481 yield* store.close();
482
483 assert.equal(NodeFS.existsSync(expiredPath), false);
484 assert.equal(NodeFS.existsSync(legacyLogPath), false);
485 assert.equal(NodeFS.existsSync(oldPath), false);
486 assert.equal(NodeFS.existsSync(newPath), true);
487 assert.equal(NodeFS.existsSync(unrelatedLogPath), true);
488 assert.equal(NodeFS.existsSync(ignoredPath), true);
489 } finally {
490 NodeFS.rmSync(tempDir, { recursive: true, force: true });
491 }
492 }),
493 );
494
495 it.effect("does not prune an active thread sink during an unrelated flush", () =>
496 Effect.gen(function* () {
497 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
498 const basePath = NodePath.join(tempDir, "events.log");
499 const activePath = ownedLogPath(basePath, "active");
500
501 try {
502 yield* TestClock.setTime(1_800_000_000_000);
503 const store = yield* makeEventNdjsonLogStore(basePath, {
504 batchWindowMs: 0,
505 maxAgeMs: 1,
506 retentionCheckIntervalMs: 1,
507 });
508 const logger = store.logger("native");
509
510 yield* logger.write({ id: "active-before-retention" }, ThreadId.make("active"));
511 assert.equal(NodeFS.existsSync(activePath), true);
512
513 yield* TestClock.adjust("2 millis");
514 yield* logger.write({ id: "retention-trigger" }, ThreadId.make("other"));
515
516 assert.equal(NodeFS.existsSync(activePath), true);
517 yield* store.close();
518 } finally {
519 NodeFS.rmSync(tempDir, { recursive: true, force: true });
520 }
521 }),
522 );
523
524 it("attributes batches that were written before a later chunk fails", () => {
525 const records: ReadonlyArray<PendingRecord> = [
526 {
527 stream: "native",
528 threadSegment: "thread",
529 line: "first",
530 bytes: 5,
531 },
532 {
533 stream: "canonical",
534 threadSegment: "thread",
535 line: "second",
536 bytes: 6,
537 },
538 ];
539 const attributed: Array<PendingRecord> = [];
540 let writes = 0;
541
542 assert.throws(() =>
543 writeBatchedMessages(
544 {
545 write: () => {
546 writes += 1;
547 if (writes === 2) throw new Error("simulated disk exhaustion");
548 },
549 },
550 records,
551 5,
552 (written) => attributed.push(...written),
553 ),
554 );
555 assert.deepEqual(attributed, [records[0]]);
556 });
557
558 it.effect("reports logical provider log writes to resource attribution", () =>
559 Effect.gen(function* () {
560 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
561 const basePath = NodePath.join(tempDir, "provider-native.ndjson");
562
563 try {
564 const attribution = yield* ResourceAttribution.make();
565 const logger = yield* makeEventNdjsonLogger(basePath, {
566 stream: "native",
567 batchWindowMs: 0,
568 attribution,
569 });
570 assert.notEqual(logger, undefined);
571 if (!logger) {
572 return;
573 }
574
575 yield* logger.write({ id: "attributed-event" }, ThreadId.make("thread-attribution"));
576 yield* logger.close();
577
578 const snapshot = yield* attribution.snapshot;
579 assert.equal(snapshot.entries.length, 1);
580 assert.equal(snapshot.entries[0]?.component, "provider-event-log");
581 assert.equal(snapshot.entries[0]?.operation, "native.append");
582 assert.equal(snapshot.entries[0]?.count, 1);
583 assert.isAbove(snapshot.entries[0]?.logicalWriteBytes ?? 0, 0);
584 } finally {
585 NodeFS.rmSync(tempDir, { recursive: true, force: true });
586 }
587 }),
588 );
243589});
apps/server/src/provider/Layers/EventNdjsonLogger.ts+527-197
......@@ -1,10 +1,9 @@
11// @effect-diagnostics nodeBuiltinImport:off
22/**
3 * Provider event logger helper.
3 * Best-effort provider event logging with one shared writer per thread.
44 *
5 * Best-effort writer for observability logs. Each record is formatted as a
6 * single effect-style text line in a thread-scoped file. Failures are
7 * downgraded to warnings so provider runtime behavior is unaffected.
5 * Native and canonical views share batching, rotation, and retention state so
6 * they cannot race while appending to the same thread-scoped file.
87 */
98import * as NodeFS from "node:fs";
109import * as NodePath from "node:path";
......@@ -12,45 +11,147 @@ import * as NodePath from "node:path";
1211import type { ThreadId } from "@t3tools/contracts";
1312import { RotatingFileSink } from "@t3tools/shared/logging";
1413import { errorTag } from "@t3tools/shared/observability";
14import * as Clock from "effect/Clock";
15import * as DateTime from "effect/DateTime";
1516import * as Effect from "effect/Effect";
1617import * as Exit from "effect/Exit";
17import * as Logger from "effect/Logger";
1818import * as Schema from "effect/Schema";
1919import * as Scope from "effect/Scope";
2020import * as SynchronizedRef from "effect/SynchronizedRef";
2121
2222import { toSafeThreadAttachmentSegment } from "../../attachmentStore.ts";
23import type { ResourceAttribution } from "../../resourceTelemetry/ResourceAttribution.ts";
2324
24const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
25const MEBIBYTE = 1024 * 1024;
26const DAY_MS = 24 * 60 * 60 * 1_000;
27const DEFAULT_MAX_BYTES = 10 * MEBIBYTE;
2528const DEFAULT_MAX_FILES = 10;
26const DEFAULT_BATCH_WINDOW_MS = 200;
29const DEFAULT_BATCH_WINDOW_MS = 1_000;
30const DEFAULT_MAX_TOTAL_BYTES = 512 * MEBIBYTE;
31const DEFAULT_MAX_AGE_MS = 14 * DAY_MS;
32const DEFAULT_RETENTION_CHECK_INTERVAL_MS = 5 * 60 * 1_000;
33const DEFAULT_MAX_BUFFERED_BYTES = MEBIBYTE;
34const DEFAULT_MAX_BUFFERED_RECORDS = 512;
2735const GLOBAL_THREAD_SEGMENT = "_global";
2836const LOG_SCOPE = "provider-observability";
2937const encodeUnknownJsonString = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString);
3038
39const transientCanonicalEventTypes = new Set([
40 "content.delta",
41 "hook.progress",
42 "item.updated",
43 "task.progress",
44 "thread.realtime.audio.delta",
45 "tool.progress",
46 "turn.proposed.delta",
47]);
48
3149export type EventNdjsonStream = "native" | "canonical" | "orchestration";
3250
3351export interface EventNdjsonLogger {
3452 readonly filePath: string;
35 write: (event: unknown, threadId: ThreadId | null) => Effect.Effect<void, never, never>;
36 close: () => Effect.Effect<void, never, never>;
53 readonly write: (event: unknown, threadId: ThreadId | null) => Effect.Effect<void>;
54 readonly close: () => Effect.Effect<void>;
3755}
3856
39export interface EventNdjsonLoggerOptions {
40 readonly stream: EventNdjsonStream;
57export interface EventNdjsonLogStore {
58 readonly filePath: string;
59 readonly logger: (stream: EventNdjsonStream) => EventNdjsonLogger;
60 readonly close: () => Effect.Effect<void>;
61}
62
63export interface EventNdjsonLogStoreOptions {
4164 readonly maxBytes?: number;
4265 readonly maxFiles?: number;
4366 readonly batchWindowMs?: number;
67 readonly maxTotalBytes?: number;
68 readonly maxAgeMs?: number;
69 readonly retentionCheckIntervalMs?: number;
70 readonly maxBufferedBytes?: number;
71 readonly maxBufferedRecords?: number;
72 readonly attribution?: ResourceAttribution["Service"];
73}
74
75export interface EventNdjsonLoggerOptions extends EventNdjsonLogStoreOptions {
76 readonly stream: EventNdjsonStream;
77}
78
79export class EventNdjsonLogConfigurationError extends Schema.TaggedErrorClass<EventNdjsonLogConfigurationError>()(
80 "EventNdjsonLogConfigurationError",
81 {
82 filePath: Schema.String,
83 option: Schema.String,
84 value: Schema.Number,
85 minimum: Schema.Number,
86 },
87) {
88 override get message(): string {
89 return `Provider event log option '${this.option}' must be an integer >= ${this.minimum}; received ${this.value} for '${this.filePath}'`;
90 }
91}
92
93export class EventNdjsonLogDirectoryError extends Schema.TaggedErrorClass<EventNdjsonLogDirectoryError>()(
94 "EventNdjsonLogDirectoryError",
95 {
96 directory: Schema.String,
97 cause: Schema.Defect(),
98 },
99) {
100 override get message(): string {
101 return `Failed to create provider event log directory '${this.directory}'`;
102 }
103}
104
105export type EventNdjsonLogStoreError =
106 | EventNdjsonLogConfigurationError
107 | EventNdjsonLogDirectoryError;
108
109interface ResolvedOptions {
110 readonly maxBytes: number;
111 readonly maxFiles: number;
112 readonly batchWindowMs: number;
113 readonly maxTotalBytes: number;
114 readonly maxAgeMs: number;
115 readonly retentionCheckIntervalMs: number;
116 readonly maxBufferedBytes: number;
117 readonly maxBufferedRecords: number;
118 readonly attribution: ResourceAttribution["Service"] | undefined;
44119}
45120
46interface ThreadWriter {
47 writeMessage: (message: string) => Effect.Effect<void>;
48 close: () => Effect.Effect<void>;
121export interface PendingRecord {
122 readonly stream: EventNdjsonStream;
123 readonly threadSegment: string;
124 readonly line: string;
125 readonly bytes: number;
49126}
50127
51interface LoggerState {
52 readonly threadWriters: Map<string, ThreadWriter>;
53 readonly failedSegments: Set<string>;
128interface StoreState {
129 readonly pending: ReadonlyArray<PendingRecord>;
130 readonly pendingBytes: number;
131 readonly sinks: ReadonlyMap<string, RotatingFileSink>;
132 readonly flushScheduled: boolean;
133 readonly closed: boolean;
134 readonly lastRetentionAt: number;
135}
136
137interface AttributionSummary {
138 readonly stream: EventNdjsonStream;
139 readonly count: number;
140 readonly logicalWriteBytes: number;
141}
142
143interface FileOperationFailure {
144 readonly filePath: string;
145 readonly cause: unknown;
146}
147
148interface RetentionResult {
149 readonly failures: ReadonlyArray<FileOperationFailure>;
150}
151
152interface DrainResult {
153 readonly attributions: ReadonlyArray<AttributionSummary>;
154 readonly failures: ReadonlyArray<FileOperationFailure>;
54155}
55156
56157function logWarning(message: string, context: Record<string, unknown>): Effect.Effect<void> {
......@@ -62,225 +163,454 @@ function resolveThreadSegment(raw: string | null | undefined): string {
62163 return normalized ?? GLOBAL_THREAD_SEGMENT;
63164}
64165
65function formatLoggerMessage(message: unknown): string {
66 if (Array.isArray(message)) {
67 return message.map((part) => (typeof part === "string" ? part : String(part))).join(" ");
166function resolveStreamLabel(stream: EventNdjsonStream): string {
167 return stream === "native" ? "NTIVE" : stream === "orchestration" ? "ORCH" : "CANON";
168}
169
170function providerLogPrefix(filePath: string): string {
171 const basename = NodePath.basename(filePath);
172 const extension = NodePath.extname(basename);
173 return `${extension.length > 0 ? basename.slice(0, -extension.length) : basename}.`;
174}
175
176function providerLogPath(directory: string, prefix: string, threadSegment: string): string {
177 return NodePath.join(directory, `${prefix}${threadSegment}.log`);
178}
179
180function shouldPersist(stream: EventNdjsonStream, event: unknown): boolean {
181 if (stream !== "canonical" || typeof event !== "object" || event === null) {
182 return true;
183 }
184 try {
185 const type = Reflect.get(event, "type");
186 return typeof type !== "string" || !transientCanonicalEventTypes.has(type);
187 } catch {
188 return true;
68189 }
69 return typeof message === "string" ? message : String(message);
70190}
71191
72function makeLineLogger(streamLabel: string): Logger.Logger<unknown, string> {
73 return Logger.make(
74 ({ date, message }) =>
75 `[${date.toISOString()}] ${streamLabel}: ${formatLoggerMessage(message)}\n`,
76 );
192export function writeBatchedMessages(
193 sink: Pick<RotatingFileSink, "write">,
194 records: ReadonlyArray<PendingRecord>,
195 maxBytes: number,
196 onWritten: (records: ReadonlyArray<PendingRecord>) => void,
197): void {
198 let pendingRecords: Array<PendingRecord> = [];
199 let pendingBytes = 0;
200
201 const flush = () => {
202 if (pendingRecords.length === 0) return;
203 const writtenRecords = pendingRecords;
204 sink.write(writtenRecords.map((record) => record.line).join(""));
205 onWritten(writtenRecords);
206 pendingRecords = [];
207 pendingBytes = 0;
208 };
209
210 for (const record of records) {
211 if (pendingBytes > 0 && pendingBytes + record.bytes > maxBytes) {
212 flush();
213 }
214 pendingRecords.push(record);
215 pendingBytes += record.bytes;
216 if (pendingBytes >= maxBytes) {
217 flush();
218 }
219 }
220 flush();
77221}
78222
79function resolveStreamLabel(stream: EventNdjsonStream): string {
80 switch (stream) {
81 case "native":
82 return "NTIVE";
83 case "canonical":
84 case "orchestration":
85 default:
86 return "CANON";
223function isProviderLogFile(filePath: string, fileName: string, filePrefix: string): boolean {
224 if (!/\.log(?:\.\d+)?$/u.test(fileName)) return false;
225 if (fileName.startsWith(filePrefix)) return true;
226
227 const descriptor = NodeFS.openSync(filePath, "r");
228 try {
229 const header = Buffer.alloc(256);
230 const bytesRead = NodeFS.readSync(descriptor, header, 0, header.byteLength, 0);
231 return /^\[[^\]\r\n]+\] (?:NTIVE|CANON|ORCH): /u.test(header.toString("utf8", 0, bytesRead));
232 } finally {
233 NodeFS.closeSync(descriptor);
87234 }
88235}
89236
90const toLogMessage = Effect.fn("toLogMessage")(function* (
91 event: unknown,
92): Effect.fn.Return<string | undefined> {
93 return yield* encodeUnknownJsonString(event).pipe(
94 Effect.catch((error) =>
95 logWarning("failed to serialize provider event log record", {
96 errorTag: errorTag(error),
97 }).pipe(Effect.as(undefined)),
98 ),
99 );
100});
237function enforceRetention(input: {
238 readonly directory: string;
239 readonly maxTotalBytes: number;
240 readonly maxAgeMs: number;
241 readonly activeFilePaths: ReadonlySet<string>;
242 readonly filePrefix: string;
243 readonly now: number;
244}): RetentionResult {
245 const failures: Array<FileOperationFailure> = [];
246 const files: Array<{ filePath: string; mtimeMs: number; size: number }> = [];
247
248 let entries: ReadonlyArray<NodeFS.Dirent>;
249 try {
250 entries = NodeFS.readdirSync(input.directory, { withFileTypes: true });
251 } catch (cause) {
252 return { failures: [{ filePath: input.directory, cause }] };
253 }
101254
102const makeThreadWriter = Effect.fn("makeThreadWriter")(function* (input: {
103 readonly filePath: string;
104 readonly maxBytes: number;
105 readonly maxFiles: number;
106 readonly batchWindowMs: number;
107 readonly streamLabel: string;
108}): Effect.fn.Return<ThreadWriter | undefined> {
109 const sinkResult = yield* Effect.sync(() => {
255 for (const entry of entries) {
256 if (!entry.isFile()) continue;
257 const filePath = NodePath.join(input.directory, entry.name);
110258 try {
111 return {
112 ok: true as const,
113 sink: new RotatingFileSink({
114 filePath: input.filePath,
115 maxBytes: input.maxBytes,
116 maxFiles: input.maxFiles,
117 throwOnError: true,
118 }),
119 };
120 } catch (error) {
121 return { ok: false as const, error };
259 if (!isProviderLogFile(filePath, entry.name, input.filePrefix)) continue;
260 const stat = NodeFS.statSync(filePath);
261 files.push({ filePath, mtimeMs: stat.mtimeMs, size: stat.size });
262 } catch (cause) {
263 failures.push({ filePath, cause });
122264 }
265 }
266
267 let totalBytes = files.reduce((total, file) => total + file.size, 0);
268 const remove = (file: (typeof files)[number]) => {
269 if (input.activeFilePaths.has(file.filePath)) return false;
270 try {
271 NodeFS.rmSync(file.filePath, { force: true });
272 totalBytes -= file.size;
273 return true;
274 } catch (cause) {
275 failures.push({ filePath: file.filePath, cause });
276 return false;
277 }
278 };
279
280 const retained = files.filter((file) => {
281 if (input.now - file.mtimeMs <= input.maxAgeMs) return true;
282 return !remove(file);
123283 });
124284
125 if (!sinkResult.ok) {
126 yield* logWarning("failed to initialize provider thread log file", {
127 filePath: input.filePath,
128 errorTag: errorTag(sinkResult.error),
129 });
130 return undefined;
285 for (const file of retained.toSorted(
286 (left, right) => left.mtimeMs - right.mtimeMs || left.filePath.localeCompare(right.filePath),
287 )) {
288 if (totalBytes <= input.maxTotalBytes) break;
289 remove(file);
131290 }
132291
133 const sink = sinkResult.sink;
134 const scope = yield* Scope.make();
135 const lineLogger = makeLineLogger(input.streamLabel);
136 const batchedLogger = yield* Logger.batched(lineLogger, {
137 window: input.batchWindowMs,
138 flush: Effect.fn("makeThreadWriter.flush")(function* (messages) {
139 const flushResult = yield* Effect.sync(() => {
140 try {
141 for (const message of messages) {
142 sink.write(message);
143 }
144 return { ok: true as const };
145 } catch (error) {
146 return { ok: false as const, error };
147 }
148 });
292 return { failures };
293}
294
295function validateOption(input: {
296 readonly filePath: string;
297 readonly option: string;
298 readonly value: number;
299 readonly minimum: number;
300}): EventNdjsonLogConfigurationError | undefined {
301 if (Number.isInteger(input.value) && input.value >= input.minimum) return undefined;
302 return new EventNdjsonLogConfigurationError(input);
303}
304
305function resolveOptions(
306 filePath: string,
307 options: EventNdjsonLogStoreOptions,
308): Effect.Effect<ResolvedOptions, EventNdjsonLogConfigurationError> {
309 const resolved = {
310 maxBytes: options.maxBytes ?? DEFAULT_MAX_BYTES,
311 maxFiles: options.maxFiles ?? DEFAULT_MAX_FILES,
312 batchWindowMs: options.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS,
313 maxTotalBytes: options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES,
314 maxAgeMs: options.maxAgeMs ?? DEFAULT_MAX_AGE_MS,
315 retentionCheckIntervalMs:
316 options.retentionCheckIntervalMs ?? DEFAULT_RETENTION_CHECK_INTERVAL_MS,
317 maxBufferedBytes: options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES,
318 maxBufferedRecords: options.maxBufferedRecords ?? DEFAULT_MAX_BUFFERED_RECORDS,
319 attribution: options.attribution,
320 } satisfies ResolvedOptions;
321
322 const validations = [
323 ["maxBytes", resolved.maxBytes, 1],
324 ["maxFiles", resolved.maxFiles, 1],
325 ["batchWindowMs", resolved.batchWindowMs, 0],
326 ["maxTotalBytes", resolved.maxTotalBytes, 1],
327 ["maxAgeMs", resolved.maxAgeMs, 1],
328 ["retentionCheckIntervalMs", resolved.retentionCheckIntervalMs, 1],
329 ["maxBufferedBytes", resolved.maxBufferedBytes, 1],
330 ["maxBufferedRecords", resolved.maxBufferedRecords, 1],
331 ] as const;
332
333 for (const [option, value, minimum] of validations) {
334 const error = validateOption({ filePath, option, value, minimum });
335 if (error) return Effect.fail(error);
336 }
337 return Effect.succeed(resolved);
338}
149339
150 if (!flushResult.ok) {
151 yield* logWarning("provider event log batch flush failed", {
152 filePath: input.filePath,
153 errorTag: errorTag(flushResult.error),
340function drainPending(input: {
341 readonly directory: string;
342 readonly options: ResolvedOptions;
343 readonly state: StoreState;
344 readonly filePrefix: string;
345 readonly now: number;
346 readonly timerFired: boolean;
347 readonly close: boolean;
348}): readonly [DrainResult, StoreState] {
349 if (input.state.closed) {
350 return [{ attributions: [], failures: [] }, input.state];
351 }
352
353 const sinks = new Map(input.state.sinks);
354 const failures: Array<FileOperationFailure> = [];
355 const attributionByStream = new Map<
356 EventNdjsonStream,
357 { count: number; logicalWriteBytes: number }
358 >();
359 const recordsBySegment = new Map<string, Array<PendingRecord>>();
360
361 for (const record of input.state.pending) {
362 const records = recordsBySegment.get(record.threadSegment) ?? [];
363 records.push(record);
364 recordsBySegment.set(record.threadSegment, records);
365 }
366
367 for (const [threadSegment, records] of recordsBySegment) {
368 const filePath = providerLogPath(input.directory, input.filePrefix, threadSegment);
369 let sink = sinks.get(threadSegment);
370 if (!sink) {
371 try {
372 sink = new RotatingFileSink({
373 filePath,
374 maxBytes: input.options.maxBytes,
375 maxFiles: input.options.maxFiles,
376 throwOnError: true,
154377 });
378 sinks.set(threadSegment, sink);
379 } catch (cause) {
380 failures.push({ filePath, cause });
381 continue;
155382 }
156 }),
157 }).pipe(Effect.provideService(Scope.Scope, scope));
383 }
158384
159 const loggerLayer = Logger.layer([batchedLogger], { mergeWithExisting: false });
385 try {
386 writeBatchedMessages(sink, records, input.options.maxBytes, (writtenRecords) => {
387 for (const record of writtenRecords) {
388 const current = attributionByStream.get(record.stream) ?? {
389 count: 0,
390 logicalWriteBytes: 0,
391 };
392 attributionByStream.set(record.stream, {
393 count: current.count + 1,
394 logicalWriteBytes: current.logicalWriteBytes + record.bytes,
395 });
396 }
397 });
398 } catch (cause) {
399 sinks.delete(threadSegment);
400 failures.push({ filePath, cause });
401 }
402 }
160403
161 return {
162 writeMessage(message: string) {
163 return Effect.log(message).pipe(Effect.provide(loggerLayer));
404 const retentionDue =
405 input.now - input.state.lastRetentionAt >= input.options.retentionCheckIntervalMs;
406 const retention = retentionDue
407 ? enforceRetention({
408 directory: input.directory,
409 maxTotalBytes: input.options.maxTotalBytes,
410 maxAgeMs: input.options.maxAgeMs,
411 activeFilePaths: new Set(
412 Array.from(sinks.keys(), (threadSegment) =>
413 providerLogPath(input.directory, input.filePrefix, threadSegment),
414 ),
415 ),
416 filePrefix: input.filePrefix,
417 now: input.now,
418 })
419 : { failures: [] };
420
421 return [
422 {
423 attributions: Array.from(attributionByStream, ([stream, value]) => ({
424 stream,
425 ...value,
426 })),
427 failures: [...failures, ...retention.failures],
164428 },
165 close() {
166 return Scope.close(scope, Exit.void);
429 {
430 pending: [],
431 pendingBytes: 0,
432 sinks,
433 flushScheduled: input.timerFired ? false : input.state.flushScheduled,
434 closed: input.close,
435 lastRetentionAt: retentionDue ? input.now : input.state.lastRetentionAt,
167436 },
168 } satisfies ThreadWriter;
437 ];
438}
439
440const serializeEvent = Effect.fnUntraced(function* (event: unknown) {
441 return yield* encodeUnknownJsonString(event).pipe(
442 Effect.catch((error) =>
443 logWarning("failed to serialize provider event log record", {
444 errorTag: errorTag(error),
445 }).pipe(Effect.as(undefined)),
446 ),
447 );
169448});
170449
171export const makeEventNdjsonLogger = Effect.fn("makeEventNdjsonLogger")(function* (
450export const makeEventNdjsonLogStore = Effect.fnUntraced(function* (
172451 filePath: string,
173 options: EventNdjsonLoggerOptions,
174): Effect.fn.Return<EventNdjsonLogger | undefined> {
175 const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
176 const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
177 const batchWindowMs = options.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS;
178 const streamLabel = resolveStreamLabel(options.stream);
179
180 const directoryReady = yield* Effect.sync(() => {
181 try {
182 NodeFS.mkdirSync(NodePath.dirname(filePath), { recursive: true });
183 return true;
184 } catch (error) {
185 return { ok: false as const, error };
186 }
452 options: EventNdjsonLogStoreOptions = {},
453): Effect.fn.Return<EventNdjsonLogStore, EventNdjsonLogStoreError> {
454 const resolved = yield* resolveOptions(filePath, options);
455 const directory = NodePath.dirname(filePath);
456 const filePrefix = providerLogPrefix(filePath);
457
458 yield* Effect.try({
459 try: () => NodeFS.mkdirSync(directory, { recursive: true }),
460 catch: (cause) => new EventNdjsonLogDirectoryError({ directory, cause }),
187461 });
188 if (directoryReady !== true) {
189 yield* logWarning("failed to create provider event log directory", {
190 filePath,
191 errorTag: errorTag(directoryReady.error),
462
463 const initializedAt = yield* Clock.currentTimeMillis;
464 const initialRetention = yield* Effect.sync(() =>
465 enforceRetention({
466 directory,
467 maxTotalBytes: resolved.maxTotalBytes,
468 maxAgeMs: resolved.maxAgeMs,
469 activeFilePaths: new Set(),
470 filePrefix,
471 now: initializedAt,
472 }),
473 );
474 for (const failure of initialRetention.failures) {
475 yield* logWarning("provider event log retention failed", {
476 filePath: failure.filePath,
477 errorTag: errorTag(failure.cause),
192478 });
193 return undefined;
194479 }
195480
196 const stateRef = yield* SynchronizedRef.make<LoggerState>({
197 threadWriters: new Map(),
198 failedSegments: new Set(),
481 const stateRef = yield* SynchronizedRef.make<StoreState>({
482 pending: [],
483 pendingBytes: 0,
484 sinks: new Map(),
485 flushScheduled: false,
486 closed: false,
487 lastRetentionAt: initializedAt,
199488 });
200
201 const resolveThreadWriter = Effect.fn("resolveThreadWriter")(function* (
202 threadSegment: string,
203 ): Effect.fn.Return<ThreadWriter | undefined> {
204 return yield* SynchronizedRef.modifyEffect(stateRef, (state) => {
205 if (state.failedSegments.has(threadSegment)) {
206 return Effect.succeed([undefined, state] as const);
207 }
208
209 const existing = state.threadWriters.get(threadSegment);
210 if (existing) {
211 return Effect.succeed([existing, state] as const);
212 }
213
214 return makeThreadWriter({
215 filePath: NodePath.join(NodePath.dirname(filePath), `${threadSegment}.log`),
216 maxBytes,
217 maxFiles,
218 batchWindowMs,
219 streamLabel,
220 }).pipe(
221 Effect.map((writer) => {
222 if (!writer) {
223 const nextFailedSegments = new Set(state.failedSegments);
224 nextFailedSegments.add(threadSegment);
225 return [
226 undefined,
227 {
228 ...state,
229 failedSegments: nextFailedSegments,
230 },
231 ] as const;
232 }
233
234 const nextThreadWriters = new Map(state.threadWriters);
235 nextThreadWriters.set(threadSegment, writer);
236 return [
237 writer,
238 {
239 ...state,
240 threadWriters: nextThreadWriters,
241 },
242 ] as const;
489 const timerScope = yield* Scope.make();
490
491 const flush = Effect.fnUntraced(function* (timerFired: boolean, close: boolean) {
492 const startedAt = yield* Clock.currentTimeMillis;
493 const result = yield* SynchronizedRef.modifyEffect(stateRef, (state) =>
494 Effect.sync(() =>
495 drainPending({
496 directory,
497 options: resolved,
498 state,
499 filePrefix,
500 now: startedAt,
501 timerFired,
502 close,
243503 }),
244 );
245 });
246 });
504 ),
505 );
247506
248 const write = Effect.fn("write")(function* (event: unknown, threadId: ThreadId | null) {
249 const threadSegment = resolveThreadSegment(threadId);
250 const message = yield* toLogMessage(event);
251 if (!message) {
252 return;
507 for (const failure of result.failures) {
508 yield* logWarning("provider event log write or retention failed", {
509 filePath: failure.filePath,
510 errorTag: errorTag(failure.cause),
511 });
253512 }
254513
255 const writer = yield* resolveThreadWriter(threadSegment);
256 if (!writer) {
257 return;
514 if (resolved.attribution && result.attributions.length > 0) {
515 const completedAt = yield* Clock.currentTimeMillis;
516 const durationMs = Math.max(0, completedAt - startedAt);
517 const totalBytes = result.attributions.reduce(
518 (total, entry) => total + entry.logicalWriteBytes,
519 0,
520 );
521 yield* Effect.forEach(
522 result.attributions,
523 (entry) =>
524 resolved.attribution?.record({
525 component: "provider-event-log",
526 operation: `${entry.stream}.append`,
527 logicalWriteBytes: entry.logicalWriteBytes,
528 count: entry.count,
529 durationMs:
530 totalBytes === 0
531 ? 0
532 : Math.round(durationMs * (entry.logicalWriteBytes / totalBytes)),
533 }) ?? Effect.void,
534 { discard: true },
535 );
258536 }
537 });
538
539 const scheduleFlush = Effect.fnUntraced(function* () {
540 yield* Effect.forkIn(
541 Effect.sleep(resolved.batchWindowMs).pipe(Effect.andThen(flush(true, false))),
542 timerScope,
543 { startImmediately: true },
544 );
545 });
259546
260 yield* writer.writeMessage(message);
547 const close = Effect.fnUntraced(function* () {
548 yield* flush(false, true);
549 yield* Scope.close(timerScope, Exit.void);
261550 });
262551
263 const close = Effect.fn("close")(function* () {
264 yield* SynchronizedRef.modifyEffect(stateRef, (state) =>
265 Effect.gen(function* () {
266 for (const writer of state.threadWriters.values()) {
267 yield* writer.close();
552 const loggerViews = new Map<EventNdjsonStream, EventNdjsonLogger>();
553 const logger = (stream: EventNdjsonStream): EventNdjsonLogger => {
554 const existing = loggerViews.get(stream);
555 if (existing) return existing;
556
557 const write = Effect.fnUntraced(function* (event: unknown, threadId: ThreadId | null) {
558 if (!shouldPersist(stream, event)) return;
559 const payload = yield* serializeEvent(event);
560 if (payload === undefined) return;
561
562 const observedAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso));
563 const line = `[${observedAt}] ${resolveStreamLabel(stream)}: ${payload}\n`;
564 const bytes = Buffer.byteLength(line);
565 const action = yield* SynchronizedRef.modifyEffect(stateRef, (state) => {
566 if (state.closed) {
567 return Effect.succeed([{ flush: false }, state] as const);
268568 }
569 const pending = [
570 ...state.pending,
571 { stream, threadSegment: resolveThreadSegment(threadId), line, bytes },
572 ];
573 const pendingBytes = state.pendingBytes + bytes;
574 const flush =
575 resolved.batchWindowMs === 0 ||
576 pending.length >= resolved.maxBufferedRecords ||
577 pendingBytes >= resolved.maxBufferedBytes;
578 const schedule = !flush && !state.flushScheduled;
579 const nextState = {
580 ...state,
581 pending,
582 pendingBytes,
583 flushScheduled: state.flushScheduled || schedule,
584 };
585 return (schedule ? scheduleFlush() : Effect.void).pipe(
586 Effect.as([{ flush }, nextState] as const),
587 );
588 }).pipe(Effect.uninterruptible);
589
590 if (action.flush) {
591 yield* flush(false, false);
592 }
593 });
269594
270 return [
271 undefined,
272 {
273 threadWriters: new Map<string, ThreadWriter>(),
274 failedSegments: new Set<string>(),
275 },
276 ] as const;
277 }),
278 );
279 });
595 const view = { filePath, write, close: () => Effect.void } satisfies EventNdjsonLogger;
596 loggerViews.set(stream, view);
597 return view;
598 };
280599
281 return {
282 filePath,
283 write,
284 close,
285 } satisfies EventNdjsonLogger;
600 return { filePath, logger, close } satisfies EventNdjsonLogStore;
601});
602
603export const makeEventNdjsonLogger = Effect.fnUntraced(function* (
604 filePath: string,
605 options: EventNdjsonLoggerOptions,
606): Effect.fn.Return<EventNdjsonLogger | undefined> {
607 const store = yield* makeEventNdjsonLogStore(filePath, options).pipe(
608 Effect.catch((error) =>
609 logWarning(error.message, { error }).pipe(
610 Effect.as<EventNdjsonLogStore | undefined>(undefined),
611 ),
612 ),
613 );
614 if (!store) return undefined;
615 return { ...store.logger(options.stream), close: store.close };
286616});
apps/server/src/provider/Layers/ProviderEventLoggers.ts+43-38
......@@ -1,6 +1,6 @@
11/**
2 * ProviderEventLoggers — single observability service that owns the two
3 * shared NDJSON streams the provider runtime writes:
2 * ProviderEventLoggers — single observability service that owns the shared
3 * provider event log store and exposes its two runtime views:
44 *
55 * - `native` — provider-protocol events as the SDK emits them, written
66 * from inside each `<X>Adapter` factory.
......@@ -13,17 +13,14 @@
1313 * not at the boot Layer. There is no longer a single `make<X>AdapterLive(options)`
1414 * call site where we can hand an `EventNdjsonLogger` in by hand.
1515 * - Multiple driver instances per kind (`codex_personal`, `codex_work`)
16 * should share one underlying log writer per stream — opening N writers
17 * against the same rotating file would race the rotation logic. Owning
18 * the loggers on a single tag keeps that invariant intact.
16 * must share one underlying log store — opening N writers against the
17 * same rotating file would race the rotation logic. Owning the loggers on
18 * a single tag keeps that invariant intact.
1919 * - Tests can swap one (or both) loggers with in-memory recorders by
2020 * `Layer.succeed(ProviderEventLoggers, { native, canonical })` instead of
2121 * juggling per-Layer option threading.
2222 *
23 * Both fields are optional. `makeEventNdjsonLogger` returns `undefined` when
24 * the target directory cannot be created; we forward that as `undefined`
25 * rather than failing the boot Layer, matching the previous best-effort
26 * behavior of `server.ts`.
23 * Both fields are optional because observability must not prevent startup.
2724 *
2825 * @module provider/Layers/ProviderEventLoggers
2926 */
......@@ -32,24 +29,23 @@ import * as Effect from "effect/Effect";
3229import * as Layer from "effect/Layer";
3330
3431import { ServerConfig } from "../../config.ts";
35import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
36
37export interface ProviderEventLoggersShape {
38 readonly native: EventNdjsonLogger | undefined;
39 readonly canonical: EventNdjsonLogger | undefined;
40}
32import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts";
33import * as EventNdjsonLogger from "./EventNdjsonLogger.ts";
4134
4235/**
4336 * Shared logger pair for native + canonical provider event streams.
4437 *
4538 * Service value is intentionally a struct of two optional loggers rather
4639 * than two parallel tags. Construction site is one place
47 * (`ProviderEventLoggersLive`); consumers (drivers, `ProviderService`) read
48 * one tag and pluck the field they need.
40 * (`layer`); consumers (drivers, `ProviderService`) read one tag and pluck the
41 * field they need.
4942 */
5043export class ProviderEventLoggers extends Context.Service<
5144 ProviderEventLoggers,
52 ProviderEventLoggersShape
45 {
46 readonly native: EventNdjsonLogger.EventNdjsonLogger | undefined;
47 readonly canonical: EventNdjsonLogger.EventNdjsonLogger | undefined;
48 }
5349>()("t3/provider/Layers/ProviderEventLoggers") {}
5450
5551/**
......@@ -57,29 +53,38 @@ export class ProviderEventLoggers extends Context.Service<
5753 * + canonical logging entirely. Keeps the tag non-optional in the type
5854 * system while letting the runtime treat absence as a no-op.
5955 */
60export const NoOpProviderEventLoggers: ProviderEventLoggersShape = {
56export const NoOpProviderEventLoggers: ProviderEventLoggers["Service"] = {
6157 native: undefined,
6258 canonical: undefined,
6359};
6460
6561/**
66 * Live Layer that builds both loggers from `ServerConfig.providerEventLogPath`.
67 * If the directory create fails for either stream, the corresponding field
68 * is `undefined` and writes from that stream become no-ops downstream.
62 * Builds both stream views over one shared store. Setup failures are logged
63 * and downgraded to the no-op service so diagnostics never block startup.
6964 */
70export const ProviderEventLoggersLive = Layer.effect(
71 ProviderEventLoggers,
72 Effect.gen(function* () {
73 const { providerEventLogPath } = yield* ServerConfig;
74 const native = yield* makeEventNdjsonLogger(providerEventLogPath, {
75 stream: "native",
76 });
77 const canonical = yield* makeEventNdjsonLogger(providerEventLogPath, {
78 stream: "canonical",
79 });
80 return {
81 native,
82 canonical,
83 } satisfies ProviderEventLoggersShape;
84 }),
85);
65export const make = Effect.gen(function* () {
66 const { providerEventLogPath } = yield* ServerConfig;
67 const attribution = yield* ResourceAttribution.ResourceAttribution;
68 const store = yield* EventNdjsonLogger.makeEventNdjsonLogStore(providerEventLogPath, {
69 attribution,
70 }).pipe(
71 Effect.catch((error) =>
72 Effect.logWarning(error.message, { error }).pipe(
73 Effect.annotateLogs({ scope: "provider-observability" }),
74 Effect.as<EventNdjsonLogger.EventNdjsonLogStore | undefined>(undefined),
75 ),
76 ),
77 );
78
79 if (!store) {
80 return ProviderEventLoggers.of(NoOpProviderEventLoggers);
81 }
82
83 yield* Effect.addFinalizer(() => store.close());
84 return ProviderEventLoggers.of({
85 native: store.logger("native"),
86 canonical: store.logger("canonical"),
87 });
88});
89
90export const layer = Layer.effect(ProviderEventLoggers, make);
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+36
......@@ -34,10 +34,13 @@ import {
3434 type ProviderInstanceConfigMap,
3535 ProviderInstanceId,
3636} from "@t3tools/contracts";
37import * as DateTime from "effect/DateTime";
3738import * as Effect from "effect/Effect";
3839import * as Layer from "effect/Layer";
40import * as Stream from "effect/Stream";
3941import { HttpClient, HttpClientResponse } from "effect/unstable/http";
4042
43import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
4144import { ServerConfig } from "../../config.ts";
4245import { ServerSettingsService } from "../../serverSettings.ts";
4346import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts";
......@@ -56,6 +59,37 @@ const TestHttpClientLive = Layer.succeed(
5659 ),
5760);
5861
62const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
63
64const BackgroundPolicyAlwaysRunLayer = Layer.mock(BackgroundPolicy.BackgroundPolicy)({
65 reportClientActivity: () => Effect.void,
66 removeRpcClient: () => Effect.void,
67 reportHostPowerState: () => Effect.void,
68 snapshot: Effect.succeed({
69 hostPower: {
70 source: "unknown",
71 idle: "unknown",
72 idleSeconds: null,
73 locked: "unknown",
74 suspended: false,
75 onBattery: "unknown",
76 lowPowerMode: "unknown",
77 thermalState: "unknown",
78 stale: true,
79 updatedAt: TEST_EPOCH,
80 },
81 leases: [],
82 activeForegroundLeaseCount: 0,
83 activeScopeKeys: [],
84 shouldRunOpportunisticWork: true,
85 updatedAt: TEST_EPOCH,
86 }),
87 streamChanges: Stream.empty,
88 hasDemand: () => Effect.succeed(true),
89 shouldRunScopeWork: () => Effect.succeed(true),
90 shouldRunOpportunisticWork: Effect.succeed(true),
91});
92
5993const makeCodexConfig = (overrides: Partial<CodexSettings>): CodexSettings => ({
6094 enabled: false,
6195 binaryPath: "codex",
......@@ -109,6 +143,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
109143 prefix: "provider-instance-registry-test",
110144 }).pipe(
111145 Layer.provideMerge(NodeServices.layer),
146 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
112147 Layer.provideMerge(ServerSettingsService.layerTest()),
113148 Layer.provideMerge(TestHttpClientLive),
114149 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
......@@ -247,6 +282,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
247282 prefix: "provider-instance-registry-all-drivers-test",
248283 }).pipe(
249284 Layer.provideMerge(infraLayer),
285 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
250286 Layer.provideMerge(ServerSettingsService.layerTest()),
251287 Layer.provideMerge(TestHttpClientLive),
252288 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
apps/server/src/provider/Layers/ProviderRegistry.test.ts+44
......@@ -1,5 +1,6 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import { describe, it, assert } from "@effect/vitest";
3import * as DateTime from "effect/DateTime";
34import * as Effect from "effect/Effect";
45import * as Exit from "effect/Exit";
56import * as Fiber from "effect/Fiber";
......@@ -32,6 +33,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
3233
3334import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts";
3435import { checkClaudeProviderStatus } from "./ClaudeProvider.ts";
36import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
3537import * as OpenCodeRuntime from "../opencodeRuntime.ts";
3638import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";
3739import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts";
......@@ -65,6 +67,7 @@ process.env.T3CODE_CURSOR_ENABLED = "1";
6567// ── Test helpers ────────────────────────────────────────────────────
6668
6769const encoder = new TextEncoder();
70const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
6871
6972const TestHttpClientLive = Layer.succeed(
7073 HttpClient.HttpClient,
......@@ -73,6 +76,35 @@ const TestHttpClientLive = Layer.succeed(
7376 ),
7477);
7578
79const BackgroundPolicyAlwaysRunLayer = Layer.mock(BackgroundPolicy.BackgroundPolicy)({
80 reportClientActivity: () => Effect.void,
81 removeRpcClient: () => Effect.void,
82 reportHostPowerState: () => Effect.void,
83 snapshot: Effect.succeed({
84 hostPower: {
85 source: "unknown",
86 idle: "unknown",
87 idleSeconds: null,
88 locked: "unknown",
89 suspended: false,
90 onBattery: "unknown",
91 lowPowerMode: "unknown",
92 thermalState: "unknown",
93 stale: true,
94 updatedAt: TEST_EPOCH,
95 },
96 leases: [],
97 activeForegroundLeaseCount: 0,
98 activeScopeKeys: [],
99 shouldRunOpportunisticWork: true,
100 updatedAt: TEST_EPOCH,
101 }),
102 streamChanges: Stream.empty,
103 hasDemand: () => Effect.succeed(true),
104 shouldRunScopeWork: () => Effect.succeed(true),
105 shouldRunOpportunisticWork: Effect.succeed(true),
106});
107
76108function selectDescriptor(
77109 id: string,
78110 label: string,
......@@ -297,6 +329,11 @@ function makeMutableServerSettingsService(
297329 get streamChanges() {
298330 return Stream.fromPubSub(changes);
299331 },
332 get subscribeChanges() {
333 return PubSub.subscribe(changes).pipe(
334 Effect.map((subscription) => Stream.fromSubscription(subscription)),
335 );
336 },
300337 } satisfies ServerSettingsModule.ServerSettingsService["Service"];
301338 });
302339}
......@@ -1000,6 +1037,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
10001037 prefix: "t3-provider-registry-merged-persist-",
10011038 }),
10021039 ),
1040 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
10031041 Layer.provideMerge(NodeServices.layer),
10041042 ),
10051043 ).pipe(Scope.provide(scope));
......@@ -1235,6 +1273,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
12351273 prefix: "t3-provider-registry-refresh-failure-",
12361274 }),
12371275 ),
1276 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
12381277 Layer.provideMerge(NodeServices.layer),
12391278 ),
12401279 ).pipe(Scope.provide(scope));
......@@ -1342,6 +1381,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
13421381 prefix: "t3-provider-registry-sync-failure-",
13431382 }),
13441383 ),
1384 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
13451385 Layer.provideMerge(NodeServices.layer),
13461386 ),
13471387 ).pipe(Scope.provide(scope));
......@@ -1446,6 +1486,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
14461486 ),
14471487 ),
14481488 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
1489 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
14491490 // NO spawner mock — `ChildProcessSpawner` is supplied by the
14501491 // outer `NodeServices.layer` on `it.layer(...)` and will
14511492 // genuinely spawn a subprocess. The missing-binary ENOENT is
......@@ -1545,6 +1586,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
15451586 }),
15461587 ),
15471588 Layer.provideMerge(NodeServices.layer),
1589 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
15481590 );
15491591 const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe(
15501592 Scope.provide(scope),
......@@ -1660,6 +1702,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
16601702 ),
16611703 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
16621704 Layer.provideMerge(NodeServices.layer),
1705 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
16631706 );
16641707 const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe(
16651708 Scope.provide(scope),
......@@ -1720,6 +1763,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
17201763 ),
17211764 ),
17221765 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
1766 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
17231767 Layer.provideMerge(
17241768 mockCommandSpawnerLayer((command, args) => {
17251769 if (command === "cursor-agent") {
apps/server/src/provider/makeManagedServerProvider.test.ts+172-5
......@@ -1,16 +1,28 @@
11import { describe, it, assert } from "@effect/vitest";
2import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
2import {
3 DEFAULT_SERVER_SETTINGS,
4 ProviderDriverKind,
5 ProviderInstanceId,
6 type ServerProvider,
7} from "@t3tools/contracts";
38import { createModelCapabilities } from "@t3tools/shared/model";
9import * as DateTime from "effect/DateTime";
410import * as Deferred from "effect/Deferred";
11import * as Duration from "effect/Duration";
512import * as Effect from "effect/Effect";
613import * as Fiber from "effect/Fiber";
14import * as Layer from "effect/Layer";
715import * as PubSub from "effect/PubSub";
816import * as Ref from "effect/Ref";
917import * as Stream from "effect/Stream";
18import { TestClock } from "effect/testing";
1019
20import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
21import { ServerSettingsService } from "../serverSettings.ts";
1122import { makeManagedServerProvider } from "./makeManagedServerProvider.ts";
1223
1324const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] });
25const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
1426const fastModeCapabilities = createModelCapabilities({
1527 optionDescriptors: [
1628 {
......@@ -87,6 +99,43 @@ const refreshedSnapshotSecond: ServerProvider = {
8799 message: "Refreshed provider availability again.",
88100};
89101
102function makeBackgroundPolicyLayer(shouldRunScopeWork: boolean) {
103 return Layer.mock(BackgroundPolicy.BackgroundPolicy)({
104 reportClientActivity: () => Effect.void,
105 removeRpcClient: () => Effect.void,
106 reportHostPowerState: () => Effect.void,
107 snapshot: Effect.succeed({
108 hostPower: {
109 source: "unknown",
110 idle: "unknown",
111 idleSeconds: null,
112 locked: "unknown",
113 suspended: false,
114 onBattery: "unknown",
115 lowPowerMode: "unknown",
116 thermalState: "unknown",
117 stale: true,
118 updatedAt: TEST_EPOCH,
119 },
120 leases: [],
121 activeForegroundLeaseCount: 0,
122 activeScopeKeys: [],
123 shouldRunOpportunisticWork: true,
124 updatedAt: TEST_EPOCH,
125 }),
126 streamChanges: Stream.empty,
127 hasDemand: () => Effect.succeed(shouldRunScopeWork),
128 shouldRunScopeWork: () => Effect.succeed(shouldRunScopeWork),
129 shouldRunOpportunisticWork: Effect.succeed(shouldRunScopeWork),
130 });
131}
132
133const BackgroundPolicyAlwaysRunLayer = makeBackgroundPolicyLayer(true);
134const BackgroundPolicyNeverRunLayer = makeBackgroundPolicyLayer(false);
135const ServerSettingsTestLayer = ServerSettingsService.layerTest();
136const AlwaysRunTestLayer = Layer.merge(BackgroundPolicyAlwaysRunLayer, ServerSettingsTestLayer);
137const NeverRunTestLayer = Layer.merge(BackgroundPolicyNeverRunLayer, ServerSettingsTestLayer);
138
90139const enrichedSnapshotSecond: ServerProvider = {
91140 ...refreshedSnapshotSecond,
92141 checkedAt: "2026-04-10T00:00:04.000Z",
......@@ -140,7 +189,125 @@ describe("makeManagedServerProvider", () => {
140189 assert.deepStrictEqual(latest, refreshedSnapshot);
141190 assert.strictEqual(yield* Ref.get(checkCalls), 1);
142191 }),
143 ),
192 ).pipe(Effect.provide(AlwaysRunTestLayer)),
193 );
194
195 it.effect("skips periodic provider refreshes without foreground provider-status demand", () =>
196 Effect.scoped(
197 Effect.gen(function* () {
198 const checkCalls = yield* Ref.make(0);
199 const initialCheckDone = yield* Deferred.make<void>();
200 yield* makeManagedServerProvider<TestSettings>({
201 maintenanceCapabilities,
202 getSettings: Effect.succeed({ enabled: true }),
203 streamSettings: Stream.empty,
204 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
205 initialSnapshot: () => Effect.succeed(initialSnapshot),
206 checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe(
207 Effect.tap((count) =>
208 count === 1
209 ? Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore)
210 : Effect.void,
211 ),
212 Effect.as(refreshedSnapshot),
213 ),
214 refreshInterval: "1 second",
215 });
216
217 yield* Deferred.await(initialCheckDone);
218 yield* TestClock.adjust("1 second");
219 yield* Effect.yieldNow;
220
221 assert.strictEqual(yield* Ref.get(checkCalls), 1);
222 }),
223 ).pipe(Effect.provide(Layer.mergeAll(NeverRunTestLayer, TestClock.layer()))),
224 );
225
226 it.effect("disables periodic provider refreshes when the explicit interval is zero", () =>
227 Effect.scoped(
228 Effect.gen(function* () {
229 const checkCalls = yield* Ref.make(0);
230 const initialCheckDone = yield* Deferred.make<void>();
231 yield* makeManagedServerProvider<TestSettings>({
232 maintenanceCapabilities,
233 getSettings: Effect.succeed({ enabled: true }),
234 streamSettings: Stream.empty,
235 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
236 initialSnapshot: () => Effect.succeed(initialSnapshot),
237 checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe(
238 Effect.tap(() => Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore)),
239 Effect.as(refreshedSnapshot),
240 ),
241 refreshInterval: 0,
242 });
243
244 yield* Deferred.await(initialCheckDone);
245 yield* TestClock.adjust("5 minutes");
246 yield* Effect.yieldNow;
247
248 assert.strictEqual(yield* Ref.get(checkCalls), 1);
249 }),
250 ).pipe(Effect.provide(Layer.mergeAll(AlwaysRunTestLayer, TestClock.layer()))),
251 );
252
253 it.effect("wakes a sleeping provider refresh loop when its interval changes", () =>
254 Effect.scoped(
255 Effect.gen(function* () {
256 const initialServerSettings = {
257 ...DEFAULT_SERVER_SETTINGS,
258 providerHealthRefreshInterval: Duration.hours(1),
259 };
260 const serverSettingsRef = yield* Ref.make(initialServerSettings);
261 const serverSettingsChanges = yield* PubSub.unbounded<typeof initialServerSettings>();
262 const serverSettingsLayer = Layer.succeed(
263 ServerSettingsService,
264 ServerSettingsService.of({
265 start: Effect.void,
266 ready: Effect.void,
267 getSettings: Ref.get(serverSettingsRef),
268 updateSettings: () => Effect.die(new Error("unused in this test")),
269 streamChanges: Stream.empty,
270 subscribeChanges: PubSub.subscribe(serverSettingsChanges).pipe(
271 Effect.map((subscription) => Stream.fromSubscription(subscription)),
272 ),
273 }),
274 );
275 const checkCalls = yield* Ref.make(0);
276 const initialCheckDone = yield* Deferred.make<void>();
277 const periodicCheckDone = yield* Deferred.make<void>();
278
279 yield* makeManagedServerProvider<TestSettings>({
280 maintenanceCapabilities,
281 getSettings: Effect.succeed({ enabled: true }),
282 streamSettings: Stream.empty,
283 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
284 initialSnapshot: () => Effect.succeed(initialSnapshot),
285 checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe(
286 Effect.tap((count) =>
287 count === 1
288 ? Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore)
289 : Deferred.succeed(periodicCheckDone, undefined).pipe(Effect.ignore),
290 ),
291 Effect.as(refreshedSnapshot),
292 ),
293 }).pipe(Effect.provide(Layer.merge(BackgroundPolicyAlwaysRunLayer, serverSettingsLayer)));
294
295 yield* Deferred.await(initialCheckDone);
296 const nextServerSettings = {
297 ...initialServerSettings,
298 providerHealthRefreshInterval: Duration.seconds(1),
299 };
300 yield* Ref.set(serverSettingsRef, nextServerSettings);
301 yield* PubSub.publish(serverSettingsChanges, nextServerSettings);
302 yield* Effect.yieldNow;
303
304 yield* TestClock.adjust("999 millis");
305 assert.strictEqual(yield* Ref.get(checkCalls), 1);
306 yield* TestClock.adjust("1 millis");
307 yield* Deferred.await(periodicCheckDone);
308 assert.strictEqual(yield* Ref.get(checkCalls), 2);
309 }),
310 ).pipe(Effect.provide(TestClock.layer())),
144311 );
145312
146313 it.effect("reruns the provider check when streamed settings change", () =>
......@@ -185,7 +352,7 @@ describe("makeManagedServerProvider", () => {
185352 assert.deepStrictEqual(latest, refreshedSnapshotSecond);
186353 assert.strictEqual(yield* Ref.get(checkCalls), 2);
187354 }),
188 ),
355 ).pipe(Effect.provide(AlwaysRunTestLayer)),
189356 );
190357
191358 it.effect("streams supplemental snapshot updates after the base provider check completes", () =>
......@@ -223,7 +390,7 @@ describe("makeManagedServerProvider", () => {
223390 assert.deepStrictEqual(updates, [refreshedSnapshot, enrichedSnapshot]);
224391 assert.deepStrictEqual(latest, enrichedSnapshot);
225392 }),
226 ),
393 ).pipe(Effect.provide(AlwaysRunTestLayer)),
227394 );
228395
229396 it.effect("ignores stale enrichment callbacks after a newer refresh advances generation", () =>
......@@ -284,6 +451,6 @@ describe("makeManagedServerProvider", () => {
284451 ]);
285452 assert.deepStrictEqual(latest, enrichedSnapshotSecond);
286453 }),
287 ),
454 ).pipe(Effect.provide(AlwaysRunTestLayer)),
288455 );
289456});
apps/server/src/provider/makeManagedServerProvider.ts+73-5
......@@ -1,16 +1,23 @@
1import type { ServerProvider } from "@t3tools/contracts";
1import {
2 DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL,
3 type ServerProvider,
4 ServerSettingsError,
5} from "@t3tools/contracts";
6import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
27import * as Duration from "effect/Duration";
38import * as Effect from "effect/Effect";
49import * as Equal from "effect/Equal";
510import * as Fiber from "effect/Fiber";
611import * as PubSub from "effect/PubSub";
12import * as Queue from "effect/Queue";
713import * as Ref from "effect/Ref";
814import * as Scope from "effect/Scope";
915import * as Stream from "effect/Stream";
1016import * as Semaphore from "effect/Semaphore";
1117
18import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
19import { ServerSettingsService } from "../serverSettings.ts";
1220import type { ServerProviderShape } from "./Services/ServerProvider.ts";
13import { ServerSettingsError } from "@t3tools/contracts";
1421
1522interface ProviderSnapshotState {
1623 readonly snapshot: ServerProvider;
......@@ -33,7 +40,13 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(
3340 readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>;
3441 }) => Effect.Effect<void>;
3542 readonly refreshInterval?: Duration.Input;
36}): Effect.fn.Return<ServerProviderShape, ServerSettingsError, Scope.Scope> {
43}): Effect.fn.Return<
44 ServerProviderShape,
45 ServerSettingsError,
46 Scope.Scope | BackgroundPolicy.BackgroundPolicy | ServerSettingsService
47> {
48 const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;
49 const serverSettings = yield* ServerSettingsService;
3750 const refreshSemaphore = yield* Semaphore.make(1);
3851 const changesPubSub = yield* Effect.acquireRelease(
3952 PubSub.unbounded<ServerProvider>(),
......@@ -134,13 +147,68 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(
134147 return yield* applySnapshot(nextSettings, { forceRefresh: true });
135148 });
136149
150 const hasProviderStatusDemand = Effect.gen(function* () {
151 const state = yield* Ref.get(snapshotStateRef);
152 const instanceId = state.snapshot.instanceId;
153 const [genericDemand, instanceDemand] = yield* Effect.all([
154 backgroundPolicy.shouldRunScopeWork({ type: "provider-status" }),
155 backgroundPolicy.shouldRunScopeWork({ type: "provider-status", instanceId }),
156 ]);
157 return genericDemand || instanceDemand;
158 });
159
160 const getRefreshInterval =
161 input.refreshInterval !== undefined
162 ? Effect.succeed(input.refreshInterval)
163 : serverSettings.getSettings.pipe(
164 Effect.map(
165 (settings) =>
166 resolveServerBackgroundActivitySettings(settings).providerHealthRefreshInterval,
167 ),
168 Effect.orElseSucceed(() => DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL),
169 );
170
171 const refreshIntervalChanges = yield* Queue.sliding<void>(1);
172 if (input.refreshInterval === undefined) {
173 const serverSettingsChanges = yield* serverSettings.subscribeChanges;
174 yield* serverSettingsChanges.pipe(
175 Stream.map((settings) =>
176 Duration.toMillis(
177 resolveServerBackgroundActivitySettings(settings).providerHealthRefreshInterval,
178 ),
179 ),
180 Stream.changes,
181 Stream.runForEach(() => Queue.offer(refreshIntervalChanges, undefined).pipe(Effect.asVoid)),
182 Effect.forkScoped,
183 );
184 }
185
137186 yield* Stream.runForEach(input.streamSettings, (nextSettings) =>
138187 Effect.asVoid(applySnapshot(nextSettings)),
139188 ).pipe(Effect.forkScoped);
140189
141190 yield* Effect.forever(
142 Effect.sleep(input.refreshInterval ?? "60 seconds").pipe(
143 Effect.flatMap(() => refreshSnapshot()),
191 getRefreshInterval.pipe(
192 Effect.flatMap((refreshInterval) =>
193 Effect.raceFirst(
194 Effect.sleep(
195 Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) <= 0
196 ? "60 seconds"
197 : refreshInterval,
198 ).pipe(Effect.as(true)),
199 Queue.take(refreshIntervalChanges).pipe(Effect.as(false)),
200 ).pipe(
201 Effect.flatMap((intervalElapsed) =>
202 intervalElapsed && Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) > 0
203 ? hasProviderStatusDemand.pipe(
204 Effect.flatMap((shouldRefresh) =>
205 shouldRefresh ? refreshSnapshot().pipe(Effect.asVoid) : Effect.void,
206 ),
207 )
208 : Effect.void,
209 ),
210 ),
211 ),
144212 Effect.ignoreCause({ log: true }),
145213 ),
146214 ).pipe(Effect.forkScoped);
apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts created+105
......@@ -0,0 +1,105 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5
6import { it } from "@effect/vitest";
7import * as DateTime from "effect/DateTime";
8import * as Effect from "effect/Effect";
9import * as Option from "effect/Option";
10import * as PubSub from "effect/PubSub";
11import * as Ref from "effect/Ref";
12import { assert, describe, expect } from "vite-plus/test";
13
14import {
15 type DesktopTelemetryReceiverHealth,
16 initialDesktopTelemetryContactAt,
17 isDesktopTelemetryContactStale,
18 recordDesktopTelemetrySampleHealth,
19 requireDesktopTelemetryWriteProgress,
20 resolveDesktopTelemetrySnapshotStaleAfterMs,
21 writeAllToFileDescriptor,
22} from "./DesktopTelemetryReceiver.ts";
23
24describe("DesktopTelemetryReceiver", () => {
25 it("degrades a hello-only stream after the first-sample deadline", () => {
26 expect(isDesktopTelemetryContactStale(Option.some(1_000), 90_999)).toBe(false);
27 expect(isDesktopTelemetryContactStale(Option.some(1_000), 91_000)).toBe(true);
28 expect(isDesktopTelemetryContactStale(Option.none(), 1_000_000)).toBe(false);
29 });
30
31 it("starts the stale deadline as soon as a telemetry descriptor is opened", () => {
32 expect(initialDesktopTelemetryContactAt(7, 1_000)).toEqual(Option.some(1_000));
33 expect(initialDesktopTelemetryContactAt(undefined, 1_000)).toEqual(Option.none());
34 });
35
36 it("keeps the snapshot deadline beyond the configured idle polling interval", () => {
37 expect(resolveDesktopTelemetrySnapshotStaleAfterMs(30_000, 120_000)).toBe(150_000);
38 expect(resolveDesktopTelemetrySnapshotStaleAfterMs(60_000, 600_000)).toBe(630_000);
39 expect(resolveDesktopTelemetrySnapshotStaleAfterMs(1_000, 1_000)).toBe(90_000);
40 });
41
42 it.effect("publishes the latest sample timestamp while health remains healthy", () =>
43 Effect.scoped(
44 Effect.gen(function* () {
45 const initialSample = DateTime.makeUnsafe(1_000);
46 const nextSample = DateTime.makeUnsafe(2_000);
47 const health = yield* Ref.make<DesktopTelemetryReceiverHealth>({
48 status: "healthy",
49 lastSampleAt: Option.some(initialSample),
50 lastError: Option.none<string>(),
51 });
52 const healthChanges = yield* PubSub.sliding<DesktopTelemetryReceiverHealth>(4);
53 const subscription = yield* PubSub.subscribe(healthChanges);
54
55 yield* recordDesktopTelemetrySampleHealth(health, healthChanges, nextSample);
56 const published = yield* PubSub.take(subscription).pipe(Effect.timeout("1 second"));
57
58 expect(DateTime.toEpochMillis(Option.getOrThrow(published.lastSampleAt))).toBe(2_000);
59 }),
60 ),
61 );
62
63 it.effect("writes control messages through the asynchronous descriptor path", () =>
64 Effect.acquireUseRelease(
65 Effect.sync(() => {
66 const directory = NodeFS.mkdtempSync(
67 NodePath.join(NodeOS.tmpdir(), "t3-desktop-telemetry-control-test-"),
68 );
69 const path = NodePath.join(directory, "control.ndjson");
70 return {
71 directory,
72 path,
73 fd: NodeFS.openSync(path, "w"),
74 };
75 }),
76 ({ fd, path }) =>
77 Effect.gen(function* () {
78 const payload = Buffer.from('{"type":"setDiagnosticsDemand","enabled":true}\n');
79 yield* writeAllToFileDescriptor(fd, payload);
80 NodeFS.fsyncSync(fd);
81
82 assert.equal(NodeFS.readFileSync(path, "utf8"), payload.toString("utf8"));
83 }),
84 ({ directory, fd }) =>
85 Effect.sync(() => {
86 NodeFS.closeSync(fd);
87 NodeFS.rmSync(directory, { recursive: true, force: true });
88 }),
89 ),
90 );
91
92 it.effect("models a zero-byte control write as a stalled descriptor", () =>
93 Effect.gen(function* () {
94 const error = yield* requireDesktopTelemetryWriteProgress(7, 42, 0).pipe(Effect.flip);
95
96 expect(error._tag).toBe("DesktopTelemetryControlStalled");
97 expect(error.fd).toBe(7);
98 expect(error.remainingBytes).toBe(42);
99 expect(error.message).toBe(
100 "Desktop telemetry control stalled on fd 7 with 42 bytes remaining.",
101 );
102 yield* requireDesktopTelemetryWriteProgress(7, 42, 1);
103 }),
104 );
105});
apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts created+662
......@@ -0,0 +1,662 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3
4import * as NodeStream from "@effect/platform-node/NodeStream";
5import {
6 DesktopHostTelemetryMessage,
7 type DesktopHostTelemetryMessage as DesktopHostTelemetryMessageValue,
8 type DesktopHostTelemetrySnapshot,
9 DesktopTelemetryControlMessage,
10 type ResourceTelemetrySourceStatus,
11} from "@t3tools/contracts";
12import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
13import * as Context from "effect/Context";
14import * as DateTime from "effect/DateTime";
15import * as Duration from "effect/Duration";
16import * as Effect from "effect/Effect";
17import * as Layer from "effect/Layer";
18import * as Option from "effect/Option";
19import * as PubSub from "effect/PubSub";
20import * as Ref from "effect/Ref";
21import * as Schema from "effect/Schema";
22import * as Semaphore from "effect/Semaphore";
23import * as Scope from "effect/Scope";
24import * as Stream from "effect/Stream";
25import * as Ndjson from "effect/unstable/encoding/Ndjson";
26
27import { ServerConfig } from "../config.ts";
28import { ServerSettingsService } from "../serverSettings.ts";
29import { subscribeBeforeSnapshotWithoutMutex } from "../utils/subscribeBeforeSnapshot.ts";
30
31const INITIAL_SAMPLE_DEADLINE_MS = 90_000;
32const MIN_SNAPSHOT_STALE_AFTER_MS = 90_000;
33const STALE_GRACE_MS = 30_000;
34const DEFAULT_HOST_POWER_ACTIVE_INTERVAL_MS = 30_000;
35const DEFAULT_HOST_POWER_IDLE_INTERVAL_MS = 120_000;
36const STALE_CHECK_INTERVAL = Duration.seconds(30);
37
38export class DesktopTelemetryDescriptorUnavailable extends Schema.TaggedErrorClass<DesktopTelemetryDescriptorUnavailable>()(
39 "DesktopTelemetryDescriptorUnavailable",
40 {
41 mode: Schema.String,
42 },
43) {
44 override get message(): string {
45 return `Desktop telemetry descriptor is unavailable in '${this.mode}' mode.`;
46 }
47}
48
49export class DesktopTelemetryProtocolMismatch extends Schema.TaggedErrorClass<DesktopTelemetryProtocolMismatch>()(
50 "DesktopTelemetryProtocolMismatch",
51 {
52 expectedVersion: Schema.Number,
53 receivedVersion: Schema.Number,
54 },
55) {
56 override get message(): string {
57 return `Desktop telemetry protocol ${this.receivedVersion} is incompatible with expected protocol ${this.expectedVersion}.`;
58 }
59}
60
61export class DesktopTelemetryDecodeFailed extends Schema.TaggedErrorClass<DesktopTelemetryDecodeFailed>()(
62 "DesktopTelemetryDecodeFailed",
63 {
64 cause: Schema.Defect(),
65 },
66) {
67 override get message(): string {
68 return "Failed to decode desktop telemetry.";
69 }
70}
71
72export class DesktopTelemetryStreamFailed extends Schema.TaggedErrorClass<DesktopTelemetryStreamFailed>()(
73 "DesktopTelemetryStreamFailed",
74 {
75 fd: Schema.Number,
76 cause: Schema.Defect(),
77 },
78) {
79 override get message(): string {
80 return `Desktop telemetry stream on fd ${this.fd} failed.`;
81 }
82}
83
84export class DesktopTelemetryStreamClosed extends Schema.TaggedErrorClass<DesktopTelemetryStreamClosed>()(
85 "DesktopTelemetryStreamClosed",
86 {
87 fd: Schema.Number,
88 },
89) {
90 override get message(): string {
91 return `Desktop telemetry stream on fd ${this.fd} closed.`;
92 }
93}
94
95export class DesktopTelemetryStale extends Schema.TaggedErrorClass<DesktopTelemetryStale>()(
96 "DesktopTelemetryStale",
97 {
98 fd: Schema.Number,
99 staleAfterMs: Schema.Number,
100 },
101) {
102 override get message(): string {
103 return `Desktop telemetry on fd ${this.fd} has not updated for ${this.staleAfterMs}ms.`;
104 }
105}
106
107export type DesktopTelemetryReceiverError =
108 | DesktopTelemetryDescriptorUnavailable
109 | DesktopTelemetryProtocolMismatch
110 | DesktopTelemetryDecodeFailed
111 | DesktopTelemetryStreamFailed
112 | DesktopTelemetryStreamClosed;
113
114export class DesktopTelemetryControlFailed extends Schema.TaggedErrorClass<DesktopTelemetryControlFailed>()(
115 "DesktopTelemetryControlFailed",
116 {
117 fd: Schema.Number,
118 operation: Schema.String,
119 cause: Schema.Defect(),
120 },
121) {
122 override get message(): string {
123 return `Desktop telemetry control '${this.operation}' failed on fd ${this.fd}.`;
124 }
125}
126
127export class DesktopTelemetryControlStalled extends Schema.TaggedErrorClass<DesktopTelemetryControlStalled>()(
128 "DesktopTelemetryControlStalled",
129 {
130 fd: Schema.Number,
131 remainingBytes: Schema.Number,
132 },
133) {
134 override get message(): string {
135 return `Desktop telemetry control stalled on fd ${this.fd} with ${this.remainingBytes} bytes remaining.`;
136 }
137}
138
139export type DesktopTelemetryControlError =
140 | DesktopTelemetryControlFailed
141 | DesktopTelemetryControlStalled;
142
143export interface DesktopTelemetryReceiverHealth {
144 readonly status: ResourceTelemetrySourceStatus;
145 readonly lastSampleAt: Option.Option<DateTime.Utc>;
146 readonly lastError: Option.Option<string>;
147}
148
149export class DesktopTelemetryReceiver extends Context.Service<
150 DesktopTelemetryReceiver,
151 {
152 readonly latest: Effect.Effect<Option.Option<DesktopHostTelemetrySnapshot>>;
153 readonly changes: Stream.Stream<DesktopHostTelemetrySnapshot>;
154 readonly subscribe: Effect.Effect<
155 {
156 readonly latest: Option.Option<DesktopHostTelemetrySnapshot>;
157 readonly changes: Stream.Stream<DesktopHostTelemetrySnapshot>;
158 },
159 never,
160 Scope.Scope
161 >;
162 readonly health: Effect.Effect<DesktopTelemetryReceiverHealth>;
163 readonly subscribeHealth: Effect.Effect<
164 {
165 readonly latest: DesktopTelemetryReceiverHealth;
166 readonly changes: Stream.Stream<DesktopTelemetryReceiverHealth>;
167 },
168 never,
169 Scope.Scope
170 >;
171 readonly setDiagnosticsDemand: (
172 enabled: boolean,
173 ) => Effect.Effect<void, DesktopTelemetryControlError>;
174 }
175>()("t3/resourceTelemetry/DesktopTelemetryReceiver") {}
176
177const decodeMessage = Schema.decodeUnknownEffect(DesktopHostTelemetryMessage);
178const encodeControlMessage = Schema.encodeEffect(
179 Schema.fromJsonString(DesktopTelemetryControlMessage),
180);
181const isDescriptorUnavailable = Schema.is(DesktopTelemetryDescriptorUnavailable);
182const isProtocolMismatch = Schema.is(DesktopTelemetryProtocolMismatch);
183const isDecodeFailed = Schema.is(DesktopTelemetryDecodeFailed);
184const isStreamFailed = Schema.is(DesktopTelemetryStreamFailed);
185
186export function isDesktopTelemetryContactStale(
187 lastContactAtMs: Option.Option<number>,
188 nowMs: number,
189): boolean {
190 return Option.exists(
191 lastContactAtMs,
192 (lastContact) => nowMs - lastContact >= INITIAL_SAMPLE_DEADLINE_MS,
193 );
194}
195
196export function resolveDesktopTelemetrySnapshotStaleAfterMs(
197 activeIntervalMs: number,
198 idleIntervalMs: number,
199): number {
200 return Math.max(
201 MIN_SNAPSHOT_STALE_AFTER_MS,
202 Math.max(activeIntervalMs, idleIntervalMs) + STALE_GRACE_MS,
203 );
204}
205
206export function initialDesktopTelemetryContactAt(
207 desktopTelemetryFd: number | undefined,
208 nowMs: number,
209): Option.Option<number> {
210 return desktopTelemetryFd === undefined ? Option.none() : Option.some(nowMs);
211}
212
213export const recordDesktopTelemetrySampleHealth = Effect.fn(
214 "resourceTelemetry.desktopTelemetryReceiver.recordSampleHealth",
215)(function* (
216 health: Ref.Ref<DesktopTelemetryReceiverHealth>,
217 healthChanges: PubSub.PubSub<DesktopTelemetryReceiverHealth>,
218 sampledAt: DateTime.Utc,
219) {
220 const next: DesktopTelemetryReceiverHealth = {
221 status: "healthy",
222 lastSampleAt: Option.some(sampledAt),
223 lastError: Option.none(),
224 };
225 yield* Ref.set(health, next);
226 yield* PubSub.publish(healthChanges, next);
227});
228
229function normalizeReceiverError(error: unknown): DesktopTelemetryReceiverError {
230 if (
231 isDescriptorUnavailable(error) ||
232 isProtocolMismatch(error) ||
233 isDecodeFailed(error) ||
234 isStreamFailed(error)
235 ) {
236 return error;
237 }
238 return new DesktopTelemetryDecodeFailed({ cause: error });
239}
240
241function messageVersion(value: unknown): number | undefined {
242 if (typeof value !== "object" || value === null) return undefined;
243 const version = Reflect.get(value, "version");
244 return typeof version === "number" ? version : undefined;
245}
246
247export const writeAllToFileDescriptor = Effect.fn(
248 "resourceTelemetry.desktopTelemetryReceiver.writeAllToFileDescriptor",
249)(function* (fd: number, payload: Buffer) {
250 let offset = 0;
251 while (offset < payload.byteLength) {
252 const written = yield* Effect.callback<number, DesktopTelemetryControlFailed>(
253 (resume, signal) => {
254 if (signal.aborted) return;
255 try {
256 NodeFS.write(
257 fd,
258 payload,
259 offset,
260 payload.byteLength - offset,
261 null,
262 (error, bytesWritten) => {
263 if (error) {
264 resume(
265 Effect.fail(
266 new DesktopTelemetryControlFailed({
267 fd,
268 operation: "write",
269 cause: error,
270 }),
271 ),
272 );
273 return;
274 }
275 resume(Effect.succeed(bytesWritten));
276 },
277 );
278 } catch (cause) {
279 resume(
280 Effect.fail(
281 new DesktopTelemetryControlFailed({
282 fd,
283 operation: "write",
284 cause,
285 }),
286 ),
287 );
288 }
289 },
290 );
291 yield* requireDesktopTelemetryWriteProgress(fd, payload.byteLength - offset, written);
292 offset += written;
293 }
294});
295
296export function requireDesktopTelemetryWriteProgress(
297 fd: number,
298 remainingBytes: number,
299 written: number,
300): Effect.Effect<void, DesktopTelemetryControlStalled> {
301 return written > 0
302 ? Effect.void
303 : Effect.fail(new DesktopTelemetryControlStalled({ fd, remainingBytes }));
304}
305
306export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make")(function* () {
307 const config = yield* ServerConfig;
308 const serverSettings = yield* ServerSettingsService;
309 const latest = yield* Ref.make(Option.none<DesktopHostTelemetrySnapshot>());
310 const receiverStartedAt = yield* DateTime.now;
311 const lastContactAtMs = yield* Ref.make(
312 initialDesktopTelemetryContactAt(
313 config.desktopTelemetryFd,
314 DateTime.toEpochMillis(receiverStartedAt),
315 ),
316 );
317 const snapshotStaleAfterMs = yield* Ref.make(
318 resolveDesktopTelemetrySnapshotStaleAfterMs(
319 DEFAULT_HOST_POWER_ACTIVE_INTERVAL_MS,
320 DEFAULT_HOST_POWER_IDLE_INTERVAL_MS,
321 ),
322 );
323 const changes = yield* PubSub.sliding<DesktopHostTelemetrySnapshot>(8);
324 const healthChanges = yield* PubSub.sliding<DesktopTelemetryReceiverHealth>(4);
325 const controlMutex = yield* Semaphore.make(1);
326 const snapshotMutex = yield* Semaphore.make(1);
327 const health = yield* Ref.make<DesktopTelemetryReceiverHealth>({
328 status: config.desktopTelemetryFd === undefined ? "unavailable" : "starting",
329 lastSampleAt: Option.none(),
330 lastError:
331 config.desktopTelemetryFd === undefined
332 ? Option.some(
333 new DesktopTelemetryDescriptorUnavailable({
334 mode: config.mode,
335 }).message,
336 )
337 : Option.none(),
338 });
339 const updateHealth = (
340 update: (current: DesktopTelemetryReceiverHealth) => DesktopTelemetryReceiverHealth,
341 ) =>
342 Ref.modify(health, (current) => {
343 const next = update(current);
344 return [next, next];
345 }).pipe(
346 Effect.flatMap((next) => PubSub.publish(healthChanges, next)),
347 Effect.asVoid,
348 );
349 const updateSampleHealth = (sampledAt: DateTime.Utc) =>
350 recordDesktopTelemetrySampleHealth(health, healthChanges, sampledAt);
351
352 const sendControlMessage = (message: DesktopTelemetryControlMessage) =>
353 controlMutex.withPermits(1)(
354 Effect.gen(function* () {
355 const fd = config.desktopTelemetryControlFd;
356 if (fd === undefined) return;
357 const encoded = yield* encodeControlMessage(message).pipe(
358 Effect.mapError(
359 (cause) =>
360 new DesktopTelemetryControlFailed({
361 fd,
362 operation: "encode",
363 cause,
364 }),
365 ),
366 );
367 yield* writeAllToFileDescriptor(fd, Buffer.from(`${encoded}\n`)).pipe(
368 Effect.tapError((error) =>
369 updateHealth((current) => ({
370 ...current,
371 status: "degraded",
372 lastError: Option.some(error.message),
373 })),
374 ),
375 );
376 }),
377 );
378 const setDiagnosticsDemand: DesktopTelemetryReceiver["Service"]["setDiagnosticsDemand"] = (
379 enabled,
380 ) =>
381 sendControlMessage({
382 version: 1,
383 type: "setDiagnosticsDemand",
384 enabled,
385 });
386
387 const sendHostPowerIntervals = (
388 settings: Parameters<typeof resolveServerBackgroundActivitySettings>[0],
389 ) => {
390 const resolved = resolveServerBackgroundActivitySettings(settings);
391 const activeIntervalMs = Math.max(
392 1,
393 Math.round(Duration.toMillis(resolved.hostPowerMonitorActiveInterval)),
394 );
395 const idleIntervalMs = Math.max(
396 1,
397 Math.round(Duration.toMillis(resolved.hostPowerMonitorIdleInterval)),
398 );
399 return sendControlMessage({
400 version: 1,
401 type: "setHostPowerIntervals",
402 activeIntervalMs,
403 idleIntervalMs,
404 }).pipe(
405 Effect.andThen(
406 Ref.set(
407 snapshotStaleAfterMs,
408 resolveDesktopTelemetrySnapshotStaleAfterMs(activeIntervalMs, idleIntervalMs),
409 ),
410 ),
411 );
412 };
413 if (config.desktopTelemetryControlFd !== undefined) {
414 const settingsChanges = yield* serverSettings.subscribeChanges;
415 const settings = yield* serverSettings.getSettings;
416 yield* sendHostPowerIntervals(settings).pipe(
417 Effect.catchCause((cause) =>
418 Effect.logWarning("Failed to configure desktop host-power intervals", {
419 cause: String(cause),
420 }),
421 ),
422 );
423 yield* settingsChanges.pipe(
424 Stream.runForEach((settings) =>
425 sendHostPowerIntervals(settings).pipe(
426 Effect.catchCause((cause) =>
427 Effect.logWarning("Failed to update desktop host-power intervals", {
428 cause: String(cause),
429 }),
430 ),
431 ),
432 ),
433 Effect.forkScoped,
434 );
435 }
436
437 if (config.desktopTelemetryFd !== undefined) {
438 const fd = config.desktopTelemetryFd;
439 const readable = yield* Effect.acquireRelease(
440 Effect.try({
441 try: () =>
442 NodeFS.createReadStream("", {
443 fd,
444 autoClose: true,
445 }),
446 catch: (cause) => new DesktopTelemetryStreamFailed({ fd, cause }),
447 }),
448 (stream) =>
449 Effect.sync(() => {
450 stream.destroy();
451 }),
452 );
453
454 const messages: Stream.Stream<DesktopHostTelemetryMessageValue, DesktopTelemetryReceiverError> =
455 NodeStream.fromReadable<Uint8Array, DesktopTelemetryStreamFailed>({
456 evaluate: () => readable,
457 closeOnDone: true,
458 onError: (cause) => new DesktopTelemetryStreamFailed({ fd, cause }),
459 }).pipe(
460 Stream.pipeThroughChannel(Ndjson.decode({ ignoreEmptyLines: true })),
461 Stream.mapEffect(
462 (
463 value,
464 ): Effect.Effect<
465 DesktopHostTelemetryMessageValue,
466 DesktopTelemetryProtocolMismatch | DesktopTelemetryDecodeFailed
467 > => {
468 const version = messageVersion(value);
469 if (version !== undefined && version !== 1) {
470 return Effect.fail(
471 new DesktopTelemetryProtocolMismatch({
472 expectedVersion: 1,
473 receivedVersion: version,
474 }),
475 );
476 }
477 return decodeMessage(value).pipe(
478 Effect.mapError((cause) => new DesktopTelemetryDecodeFailed({ cause })),
479 );
480 },
481 ),
482 Stream.mapError(normalizeReceiverError),
483 );
484
485 yield* messages.pipe(
486 Stream.runForEach((message) => {
487 const recordContact = DateTime.now.pipe(
488 Effect.flatMap((now) =>
489 Ref.set(lastContactAtMs, Option.some(DateTime.toEpochMillis(now))),
490 ),
491 );
492 if (message.type === "desktopTelemetryHello") {
493 return recordContact.pipe(
494 Effect.andThen(
495 updateHealth(
496 (current): DesktopTelemetryReceiverHealth => ({
497 ...current,
498 status: "healthy",
499 lastError: Option.none(),
500 }),
501 ),
502 ),
503 );
504 }
505
506 const sampledAt = DateTime.makeUnsafe(message.sampledAtUnixMs);
507 return snapshotMutex.withPermits(1)(
508 recordContact.pipe(
509 Effect.andThen(Ref.set(latest, Option.some(message))),
510 Effect.andThen(updateSampleHealth(sampledAt)),
511 Effect.andThen(PubSub.publish(changes, message)),
512 Effect.asVoid,
513 ),
514 );
515 }),
516 Effect.andThen(
517 updateHealth(
518 (current): DesktopTelemetryReceiverHealth => ({
519 ...current,
520 status: "stopped",
521 lastError: Option.some(new DesktopTelemetryStreamClosed({ fd }).message),
522 }),
523 ),
524 ),
525 Effect.catch((error) =>
526 updateHealth(
527 (current): DesktopTelemetryReceiverHealth => ({
528 ...current,
529 status: "degraded",
530 lastError: Option.some(error.message),
531 }),
532 ),
533 ),
534 Effect.forkScoped,
535 );
536
537 yield* Effect.forever(
538 Effect.sleep(STALE_CHECK_INTERVAL).pipe(
539 Effect.andThen(
540 snapshotMutex.withPermits(1)(
541 Effect.gen(function* () {
542 const now = yield* DateTime.now;
543 const nowMs = DateTime.toEpochMillis(now);
544 const staleAfterMs = yield* Ref.get(snapshotStaleAfterMs);
545 const staleSnapshot = yield* Ref.modify(latest, (current) => {
546 if (
547 Option.isNone(current) ||
548 current.value.power.stale ||
549 nowMs - current.value.sampledAtUnixMs < staleAfterMs
550 ) {
551 return [Option.none<DesktopHostTelemetrySnapshot>(), current] as const;
552 }
553 const stale: DesktopHostTelemetrySnapshot = {
554 ...current.value,
555 power: { ...current.value.power, stale: true },
556 };
557 return [Option.some(stale), Option.some(stale)] as const;
558 });
559 if (Option.isNone(staleSnapshot)) {
560 const lastContact = yield* Ref.get(lastContactAtMs);
561 if (!isDesktopTelemetryContactStale(lastContact, nowMs)) return;
562 const staleMessage = new DesktopTelemetryStale({
563 fd,
564 staleAfterMs: INITIAL_SAMPLE_DEADLINE_MS,
565 }).message;
566 const changed = yield* Ref.modify(health, (current) => {
567 if (
568 current.status === "stopped" ||
569 Option.isSome(current.lastSampleAt) ||
570 (current.status === "degraded" &&
571 Option.contains(current.lastError, staleMessage))
572 ) {
573 return [Option.none<DesktopTelemetryReceiverHealth>(), current] as const;
574 }
575 const next: DesktopTelemetryReceiverHealth = {
576 ...current,
577 status: "degraded",
578 lastError: Option.some(staleMessage),
579 };
580 return [Option.some(next), next] as const;
581 });
582 if (Option.isSome(changed)) {
583 yield* PubSub.publish(healthChanges, changed.value);
584 }
585 return;
586 }
587 yield* updateHealth((currentHealth) => ({
588 ...currentHealth,
589 status: currentHealth.status === "stopped" ? "stopped" : "degraded",
590 lastError:
591 currentHealth.status === "stopped"
592 ? currentHealth.lastError
593 : Option.some(new DesktopTelemetryStale({ fd, staleAfterMs }).message),
594 }));
595 yield* PubSub.publish(changes, staleSnapshot.value);
596 }),
597 ),
598 ),
599 ),
600 ).pipe(Effect.forkScoped);
601 }
602
603 return DesktopTelemetryReceiver.of({
604 latest: Ref.get(latest),
605 changes: Stream.fromPubSub(changes),
606 subscribe: snapshotMutex.withPermits(1)(
607 Effect.gen(function* () {
608 const initial = yield* Ref.get(latest);
609 const subscription = yield* PubSub.subscribe(changes);
610 return {
611 latest: initial,
612 changes: Stream.fromSubscription(subscription),
613 };
614 }),
615 ),
616 health: Ref.get(health),
617 subscribeHealth: subscribeBeforeSnapshotWithoutMutex(healthChanges, Ref.get(health)),
618 setDiagnosticsDemand,
619 });
620});
621
622export const layer = Layer.effect(DesktopTelemetryReceiver, make());
623
624export const layerTest = (
625 overrides: Partial<DesktopTelemetryReceiver["Service"]> = {},
626): Layer.Layer<DesktopTelemetryReceiver> => {
627 const latest = overrides.latest ?? Effect.succeedNone;
628 const changes = overrides.changes ?? Stream.empty;
629 const health =
630 overrides.health ??
631 Effect.succeed({
632 status: "unavailable" as const,
633 lastSampleAt: Option.none<DateTime.Utc>(),
634 lastError: Option.some("Desktop telemetry test implementation is unavailable."),
635 });
636 return Layer.succeed(
637 DesktopTelemetryReceiver,
638 DesktopTelemetryReceiver.of({
639 latest,
640 changes,
641 subscribe:
642 overrides.subscribe ??
643 latest.pipe(
644 Effect.map((initial) => ({
645 latest: initial,
646 changes,
647 })),
648 ),
649 health,
650 subscribeHealth:
651 overrides.subscribeHealth ??
652 health.pipe(
653 Effect.map((initial) => ({
654 latest: initial,
655 changes: Stream.empty,
656 })),
657 ),
658 setDiagnosticsDemand: () => Effect.void,
659 ...overrides,
660 }),
661 );
662};
apps/server/src/resourceTelemetry/Model.test.ts created+581
......@@ -0,0 +1,581 @@
1import {
2 type DesktopElectronProcessMetric,
3 type DesktopHostTelemetrySnapshot,
4 type ResourceMonitorProcessSample,
5 type ResourceMonitorSnapshotEvent,
6} from "@t3tools/contracts";
7import { describe, expect, it } from "@effect/vitest";
8import * as DateTime from "effect/DateTime";
9import * as Option from "effect/Option";
10
11import { emptyTelemetryCounters, mergeProcesses, type MergeProcessesResult } from "./Model.ts";
12
13const SERVER_PID = 100;
14const BASE_TIME_MS = DateTime.toEpochMillis(DateTime.makeUnsafe("2026-06-17T12:00:00.000Z"));
15
16function processSample(
17 input: Partial<ResourceMonitorProcessSample> &
18 Pick<ResourceMonitorProcessSample, "pid" | "ppid" | "startTimeMs">,
19): ResourceMonitorProcessSample {
20 return {
21 runTimeMs: 1_000,
22 name: `process-${input.pid}`,
23 command: `process-${input.pid}`,
24 status: "Running",
25 cpuPercent: 0,
26 cpuTimeMs: 0,
27 residentBytes: 1_024,
28 virtualBytes: 2_048,
29 ioReadBytes: 0,
30 ioWriteBytes: 0,
31 ioSemantics: "storage",
32 ...input,
33 };
34}
35
36function nativeSnapshot(
37 sampledAtUnixMs: number,
38 processes: ReadonlyArray<ResourceMonitorProcessSample>,
39 sequence = 1,
40): ResourceMonitorSnapshotEvent {
41 return {
42 version: 2,
43 type: "snapshot",
44 sequence,
45 sampledAtUnixMs,
46 collectionDurationMicros: 250,
47 scannedProcessCount: processes.length,
48 retainedProcessCount: processes.length,
49 inaccessibleProcessCount: 0,
50 processes: [...processes],
51 };
52}
53
54function electronMetric(
55 input: Partial<DesktopElectronProcessMetric> &
56 Pick<DesktopElectronProcessMetric, "pid" | "creationTimeMs" | "type">,
57): DesktopElectronProcessMetric {
58 return {
59 cpuPercent: 0,
60 idleWakeupsPerSecond: 0,
61 workingSetBytes: 1_024,
62 peakWorkingSetBytes: 2_048,
63 ...input,
64 };
65}
66
67function desktopSnapshot(
68 sampledAtUnixMs: number,
69 electronProcesses: ReadonlyArray<DesktopElectronProcessMetric>,
70): DesktopHostTelemetrySnapshot {
71 const sampledAt = DateTime.makeUnsafe(sampledAtUnixMs);
72 return {
73 version: 1,
74 type: "desktopTelemetry",
75 sequence: 1,
76 sampledAtUnixMs,
77 electronPid: electronProcesses[0]?.pid ?? 10_000,
78 power: {
79 source: "electron-main",
80 idle: "false",
81 idleSeconds: 0,
82 locked: "false",
83 suspended: false,
84 onBattery: "false",
85 lowPowerMode: "unknown",
86 thermalState: "nominal",
87 stale: false,
88 updatedAt: sampledAt,
89 },
90 speedLimitPercent: Option.none(),
91 electronProcesses: [...electronProcesses],
92 };
93}
94
95function merge(input: {
96 readonly native: ResourceMonitorSnapshotEvent;
97 readonly desktop?: DesktopHostTelemetrySnapshot;
98 readonly previous?: MergeProcessesResult;
99 readonly sidecarPid?: number;
100}): MergeProcessesResult {
101 return mergeProcesses({
102 serverPid: SERVER_PID,
103 sidecarPid: Option.fromUndefinedOr(input.sidecarPid),
104 fallbackSampledAtMs: input.native.sampledAtUnixMs,
105 nativeSnapshot: Option.some(input.native),
106 desktopSnapshot: Option.fromUndefinedOr(input.desktop),
107 previous: input.previous?.previous ?? new Map(),
108 counters: input.previous?.counters ?? emptyTelemetryCounters(),
109 updatePrevious: true,
110 });
111}
112
113describe("resource telemetry process model", () => {
114 it("builds complete descendant depths and isolates monitor overhead", () => {
115 const result = merge({
116 sidecarPid: 900,
117 native: nativeSnapshot(BASE_TIME_MS, [
118 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
119 processSample({ pid: 200, ppid: SERVER_PID, startTimeMs: 2_000 }),
120 processSample({ pid: 201, ppid: 200, startTimeMs: 3_000 }),
121 processSample({ pid: 202, ppid: 201, startTimeMs: 4_000 }),
122 processSample({ pid: 900, ppid: SERVER_PID, startTimeMs: 5_000 }),
123 ]),
124 });
125
126 expect(result.processes.map((process) => [process.identity.pid, process.depth])).toEqual([
127 [100, 0],
128 [200, 1],
129 [201, 2],
130 [202, 3],
131 [900, 1],
132 ]);
133 expect(result.processes.find((process) => process.identity.pid === 900)?.category).toBe(
134 "resource-monitor",
135 );
136 expect(result.groups.backend.processCount).toBe(4);
137 expect(result.groups.monitor.processCount).toBe(1);
138 expect(result.groups.monitor.processStarts).toBe(1);
139 expect(result.groups.allT3.processStarts).toBe(5);
140 });
141
142 it("deduplicates Electron metrics and classifies Electron descendants", () => {
143 const electronStart = 10_000;
144 const result = merge({
145 native: nativeSnapshot(BASE_TIME_MS, [
146 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
147 processSample({ pid: 300, ppid: 1, startTimeMs: electronStart }),
148 processSample({ pid: 301, ppid: 300, startTimeMs: electronStart + 1 }),
149 ]),
150 desktop: desktopSnapshot(BASE_TIME_MS, [
151 electronMetric({
152 pid: 300,
153 creationTimeMs: electronStart + 500,
154 type: "Browser",
155 name: "electron",
156 }),
157 electronMetric({
158 pid: 301,
159 creationTimeMs: electronStart + 500,
160 type: "Utility",
161 name: "network-service",
162 }),
163 ]),
164 });
165
166 expect(result.processes.filter((process) => process.identity.pid === 300)).toHaveLength(1);
167 expect(result.processes.find((process) => process.identity.pid === 300)?.category).toBe(
168 "electron-main",
169 );
170 expect(result.processes.find((process) => process.identity.pid === 301)?.category).toBe(
171 "electron-utility",
172 );
173 expect(result.processes.find((process) => process.identity.pid === 301)?.depth).toBe(1);
174 expect(result.groups.electron.processCount).toBe(2);
175 });
176
177 it("ignores stale Electron metrics after PID reuse", () => {
178 const result = merge({
179 native: nativeSnapshot(BASE_TIME_MS, [
180 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
181 processSample({ pid: 300, ppid: SERVER_PID, startTimeMs: 50_000 }),
182 ]),
183 desktop: desktopSnapshot(BASE_TIME_MS, [
184 electronMetric({
185 pid: 300,
186 creationTimeMs: 10_000,
187 type: "Browser",
188 }),
189 ]),
190 });
191
192 expect(result.processes.find((process) => process.identity.pid === 300)?.category).toBe(
193 "server-child",
194 );
195 expect(result.groups.electron.processCount).toBe(0);
196 });
197
198 it("derives cumulative CPU time for synthetic Electron-only processes", () => {
199 const first = merge({
200 native: nativeSnapshot(BASE_TIME_MS, [
201 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
202 ]),
203 desktop: desktopSnapshot(BASE_TIME_MS, [
204 electronMetric({
205 pid: 300,
206 creationTimeMs: 10_000,
207 type: "Browser",
208 cpuPercent: 50,
209 }),
210 ]),
211 });
212 const second = merge({
213 previous: first,
214 native: nativeSnapshot(
215 BASE_TIME_MS + 1_000,
216 [processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 })],
217 2,
218 ),
219 desktop: desktopSnapshot(BASE_TIME_MS + 1_000, [
220 electronMetric({
221 pid: 300,
222 creationTimeMs: 10_000,
223 type: "Browser",
224 cpuPercent: 50,
225 }),
226 ]),
227 });
228
229 expect(second.processes.find((process) => process.identity.pid === 300)?.cpuTimeMs).toBe(500);
230 expect(second.groups.electron.cpuTimeMs).toBe(500);
231 });
232
233 it("uses the native timestamp for native cumulative-counter deltas", () => {
234 const first = merge({
235 native: nativeSnapshot(BASE_TIME_MS, [
236 processSample({
237 pid: SERVER_PID,
238 ppid: 1,
239 startTimeMs: 1_000,
240 cpuTimeMs: 1_000,
241 }),
242 ]),
243 desktop: desktopSnapshot(BASE_TIME_MS + 10_000, []),
244 });
245 const second = merge({
246 previous: first,
247 native: nativeSnapshot(
248 BASE_TIME_MS + 1_000,
249 [
250 processSample({
251 pid: SERVER_PID,
252 ppid: 1,
253 startTimeMs: 1_000,
254 cpuTimeMs: 1_500,
255 }),
256 ],
257 2,
258 ),
259 desktop: desktopSnapshot(BASE_TIME_MS + 11_000, []),
260 });
261
262 expect(second.processes[0]?.cpuPercent).toBe(50);
263 expect(second.groups.backend.cpuTimeMs).toBe(500);
264 });
265
266 it("does not advance synthetic CPU time when reusing the same desktop sample", () => {
267 const metric = electronMetric({
268 pid: 300,
269 creationTimeMs: 10_000,
270 type: "Browser",
271 cpuPercent: 50,
272 });
273 const first = merge({
274 native: nativeSnapshot(BASE_TIME_MS, [
275 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
276 ]),
277 desktop: desktopSnapshot(BASE_TIME_MS, [metric]),
278 });
279 const second = merge({
280 previous: first,
281 native: nativeSnapshot(
282 BASE_TIME_MS + 1_000,
283 [processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 })],
284 2,
285 ),
286 desktop: desktopSnapshot(BASE_TIME_MS, [metric]),
287 });
288
289 expect(second.processes.find((process) => process.identity.pid === 300)?.cpuTimeMs).toBe(0);
290 expect(second.groups.electron.cpuTimeMs).toBe(0);
291 });
292
293 it("does not apply an explicit Electron root to a reused PID", () => {
294 const first = mergeProcesses({
295 serverPid: SERVER_PID,
296 sidecarPid: Option.none(),
297 electronRootPids: new Set([300]),
298 fallbackSampledAtMs: BASE_TIME_MS,
299 nativeSnapshot: Option.some(
300 nativeSnapshot(BASE_TIME_MS, [
301 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
302 processSample({ pid: 300, ppid: 1, startTimeMs: 10_000 }),
303 ]),
304 ),
305 desktopSnapshot: Option.some(
306 desktopSnapshot(BASE_TIME_MS, [
307 electronMetric({
308 pid: 300,
309 creationTimeMs: 10_000,
310 type: "Browser",
311 }),
312 ]),
313 ),
314 previous: new Map(),
315 counters: emptyTelemetryCounters(),
316 updatePrevious: true,
317 });
318 const reused = mergeProcesses({
319 serverPid: SERVER_PID,
320 sidecarPid: Option.none(),
321 electronRootPids: new Set([300]),
322 fallbackSampledAtMs: BASE_TIME_MS + 1_000,
323 nativeSnapshot: Option.some(
324 nativeSnapshot(
325 BASE_TIME_MS + 1_000,
326 [
327 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
328 processSample({ pid: 300, ppid: SERVER_PID, startTimeMs: 20_000 }),
329 ],
330 2,
331 ),
332 ),
333 desktopSnapshot: Option.none(),
334 previous: first.previous,
335 counters: first.counters,
336 updatePrevious: true,
337 });
338
339 expect(reused.processes.find((process) => process.identity.pid === 300)?.category).toBe(
340 "server-child",
341 );
342 expect(reused.groups.electron.processCount).toBe(0);
343 });
344
345 it("derives rates from cumulative counters and preserves I/O semantics", () => {
346 const first = merge({
347 native: nativeSnapshot(BASE_TIME_MS, [
348 processSample({
349 pid: SERVER_PID,
350 ppid: 1,
351 startTimeMs: 1_000,
352 cpuTimeMs: 1_000,
353 ioReadBytes: 10_000,
354 ioWriteBytes: 20_000,
355 ioSemantics: "all-io",
356 }),
357 ]),
358 });
359 const second = merge({
360 previous: first,
361 native: nativeSnapshot(
362 BASE_TIME_MS + 1_000,
363 [
364 processSample({
365 pid: SERVER_PID,
366 ppid: 1,
367 startTimeMs: 1_000,
368 cpuTimeMs: 1_250,
369 ioReadBytes: 12_000,
370 ioWriteBytes: 23_000,
371 ioSemantics: "all-io",
372 }),
373 ],
374 2,
375 ),
376 });
377 const server = second.processes[0]!;
378
379 expect(server.cpuPercent).toBe(25);
380 expect(server.ioReadBytesPerSecond).toBe(2_000);
381 expect(server.ioWriteBytesPerSecond).toBe(3_000);
382 expect(server.ioSemantics).toBe("all-io");
383 expect(second.groups.backend.cpuTimeMs).toBe(250);
384 expect(second.groups.backend.ioReadBytes).toBe(2_000);
385 expect(second.groups.backend.ioWriteBytes).toBe(3_000);
386 });
387
388 it("derives deltas at the constrained 15-second sampling cadence", () => {
389 const first = merge({
390 native: nativeSnapshot(BASE_TIME_MS, [
391 processSample({
392 pid: SERVER_PID,
393 ppid: 1,
394 startTimeMs: 1_000,
395 cpuTimeMs: 1_000,
396 ioReadBytes: 10_000,
397 ioWriteBytes: 20_000,
398 }),
399 ]),
400 });
401 const second = merge({
402 previous: first,
403 native: nativeSnapshot(
404 BASE_TIME_MS + 15_000,
405 [
406 processSample({
407 pid: SERVER_PID,
408 ppid: 1,
409 startTimeMs: 1_000,
410 cpuTimeMs: 2_500,
411 ioReadBytes: 25_000,
412 ioWriteBytes: 50_000,
413 }),
414 ],
415 2,
416 ),
417 });
418
419 expect(second.processes[0]?.cpuPercent).toBe(10);
420 expect(second.processes[0]?.ioReadBytesPerSecond).toBe(1_000);
421 expect(second.processes[0]?.ioWriteBytesPerSecond).toBe(2_000);
422 expect(second.groups.backend.cpuTimeMs).toBe(1_500);
423 expect(second.groups.backend.ioReadBytes).toBe(15_000);
424 expect(second.groups.backend.ioWriteBytes).toBe(30_000);
425 });
426
427 it("preserves native rates while applying a desktop-only update", () => {
428 const first = merge({
429 native: nativeSnapshot(BASE_TIME_MS, [
430 processSample({
431 pid: SERVER_PID,
432 ppid: 1,
433 startTimeMs: 1_000,
434 cpuTimeMs: 1_000,
435 ioReadBytes: 10_000,
436 ioWriteBytes: 20_000,
437 }),
438 ]),
439 });
440 const second = merge({
441 previous: first,
442 native: nativeSnapshot(
443 BASE_TIME_MS + 1_000,
444 [
445 processSample({
446 pid: SERVER_PID,
447 ppid: 1,
448 startTimeMs: 1_000,
449 cpuTimeMs: 1_250,
450 ioReadBytes: 12_000,
451 ioWriteBytes: 23_000,
452 }),
453 ],
454 2,
455 ),
456 });
457 const desktopOnly = mergeProcesses({
458 serverPid: SERVER_PID,
459 sidecarPid: Option.none(),
460 fallbackSampledAtMs: BASE_TIME_MS + 1_000,
461 nativeSnapshot: Option.some(
462 nativeSnapshot(
463 BASE_TIME_MS + 1_000,
464 [
465 processSample({
466 pid: SERVER_PID,
467 ppid: 1,
468 startTimeMs: 1_000,
469 cpuTimeMs: 1_250,
470 ioReadBytes: 12_000,
471 ioWriteBytes: 23_000,
472 }),
473 ],
474 2,
475 ),
476 ),
477 desktopSnapshot: Option.some(desktopSnapshot(BASE_TIME_MS + 1_500, [])),
478 previous: second.previous,
479 counters: second.counters,
480 updatePrevious: false,
481 });
482
483 expect(desktopOnly.processes[0]?.cpuPercent).toBe(25);
484 expect(desktopOnly.processes[0]?.ioReadBytesPerSecond).toBe(2_000);
485 expect(desktopOnly.processes[0]?.ioWriteBytesPerSecond).toBe(3_000);
486 expect(desktopOnly.sampledAtMs).toBe(BASE_TIME_MS + 1_500);
487 });
488
489 it("resets deltas when counters decrease or the sampling gap is unsafe", () => {
490 const first = merge({
491 native: nativeSnapshot(BASE_TIME_MS, [
492 processSample({
493 pid: SERVER_PID,
494 ppid: 1,
495 startTimeMs: 1_000,
496 cpuTimeMs: 1_000,
497 ioReadBytes: 10_000,
498 ioWriteBytes: 20_000,
499 }),
500 ]),
501 });
502 const decreased = merge({
503 previous: first,
504 native: nativeSnapshot(
505 BASE_TIME_MS + 1_000,
506 [
507 processSample({
508 pid: SERVER_PID,
509 ppid: 1,
510 startTimeMs: 1_000,
511 cpuTimeMs: 100,
512 ioReadBytes: 100,
513 ioWriteBytes: 200,
514 }),
515 ],
516 2,
517 ),
518 });
519 const delayed = merge({
520 previous: decreased,
521 native: nativeSnapshot(
522 BASE_TIME_MS + 90_000,
523 [
524 processSample({
525 pid: SERVER_PID,
526 ppid: 1,
527 startTimeMs: 1_000,
528 cpuTimeMs: 10_000,
529 ioReadBytes: 100_000,
530 ioWriteBytes: 200_000,
531 }),
532 ],
533 3,
534 ),
535 });
536
537 expect(decreased.processes[0]?.cpuPercent).toBe(0);
538 expect(decreased.processes[0]?.ioReadBytesPerSecond).toBe(0);
539 expect(decreased.processes[0]?.ioWriteBytesPerSecond).toBe(0);
540 expect(delayed.processes[0]?.cpuPercent).toBe(0);
541 expect(delayed.processes[0]?.ioReadBytesPerSecond).toBe(0);
542 expect(delayed.processes[0]?.ioWriteBytesPerSecond).toBe(0);
543 expect(delayed.groups.backend.cpuTimeMs).toBe(0);
544 expect(delayed.groups.backend.ioReadBytes).toBe(0);
545 expect(delayed.groups.backend.ioWriteBytes).toBe(0);
546 });
547
548 it("treats reused PIDs as an exit plus a new process", () => {
549 const first = merge({
550 native: nativeSnapshot(BASE_TIME_MS, [
551 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
552 processSample({ pid: 200, ppid: SERVER_PID, startTimeMs: 2_000 }),
553 ]),
554 });
555 const second = merge({
556 previous: first,
557 native: nativeSnapshot(
558 BASE_TIME_MS + 1_000,
559 [
560 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
561 processSample({
562 pid: 200,
563 ppid: SERVER_PID,
564 startTimeMs: 9_000,
565 cpuTimeMs: 999,
566 ioReadBytes: 999,
567 ioWriteBytes: 999,
568 }),
569 ],
570 2,
571 ),
572 });
573 const reused = second.processes.find((process) => process.identity.pid === 200)!;
574
575 expect(reused.identity.startTimeMs).toBe(9_000);
576 expect(reused.cpuPercent).toBe(0);
577 expect(reused.ioReadBytesPerSecond).toBe(0);
578 expect(second.groups.backend.processStarts).toBe(3);
579 expect(second.groups.backend.processExits).toBe(1);
580 });
581});
apps/server/src/resourceTelemetry/Model.ts created+608
......@@ -0,0 +1,608 @@
1import type {
2 DesktopElectronProcessMetric,
3 DesktopHostTelemetrySnapshot,
4 ResourceMonitorProcessSample,
5 ResourceMonitorSnapshotEvent,
6 ResourceTelemetryAggregate,
7 ResourceTelemetryProcess,
8 ResourceTelemetryProcessCategory,
9} from "@t3tools/contracts";
10import * as DateTime from "effect/DateTime";
11import * as Option from "effect/Option";
12
13const MAX_DELTA_INTERVAL_MS = 30_000;
14const ELECTRON_IDENTITY_TOLERANCE_MS = 2_000;
15
16export interface ProcessState {
17 readonly process: ResourceTelemetryProcess;
18 readonly sampledAtMs: number;
19}
20
21export interface GroupCounters {
22 readonly cpuTimeMs: number;
23 readonly ioReadBytes: number;
24 readonly ioWriteBytes: number;
25 readonly processStarts: number;
26 readonly processExits: number;
27}
28
29export interface TelemetryCounters {
30 readonly backend: GroupCounters;
31 readonly electron: GroupCounters;
32 readonly monitor: GroupCounters;
33 readonly allT3: GroupCounters;
34}
35
36export interface ProcessDelta {
37 readonly identityKey: string;
38 readonly category: ResourceTelemetryProcessCategory;
39 readonly cpuTimeMs: number;
40 readonly ioReadBytes: number;
41 readonly ioWriteBytes: number;
42}
43
44export interface MergeProcessesInput {
45 readonly serverPid: number;
46 readonly sidecarPid: Option.Option<number>;
47 readonly fallbackSampledAtMs: number;
48 readonly nativeSnapshot: Option.Option<ResourceMonitorSnapshotEvent>;
49 readonly desktopSnapshot: Option.Option<DesktopHostTelemetrySnapshot>;
50 readonly electronRootPids?: ReadonlySet<number>;
51 readonly electronRootStartTimes?: ReadonlyMap<number, number>;
52 readonly previous: ReadonlyMap<string, ProcessState>;
53 readonly counters: TelemetryCounters;
54 readonly updatePrevious: boolean;
55}
56
57export interface MergeProcessesResult {
58 readonly sampledAtMs: number;
59 readonly processes: ReadonlyArray<ResourceTelemetryProcess>;
60 readonly previous: ReadonlyMap<string, ProcessState>;
61 readonly counters: TelemetryCounters;
62 readonly groups: {
63 readonly backend: ResourceTelemetryAggregate;
64 readonly electron: ResourceTelemetryAggregate;
65 readonly monitor: ResourceTelemetryAggregate;
66 readonly allT3: ResourceTelemetryAggregate;
67 };
68 readonly deltas: ReadonlyArray<ProcessDelta>;
69}
70
71export const emptyGroupCounters = (): GroupCounters => ({
72 cpuTimeMs: 0,
73 ioReadBytes: 0,
74 ioWriteBytes: 0,
75 processStarts: 0,
76 processExits: 0,
77});
78
79export const emptyTelemetryCounters = (): TelemetryCounters => ({
80 backend: emptyGroupCounters(),
81 electron: emptyGroupCounters(),
82 monitor: emptyGroupCounters(),
83 allT3: emptyGroupCounters(),
84});
85
86export function processIdentityKey(pid: number, startTimeMs: number): string {
87 return `${pid}:${startTimeMs}`;
88}
89
90function finiteNonNegative(value: number): number {
91 return Number.isFinite(value) ? Math.max(0, value) : 0;
92}
93
94function categoryGroup(
95 category: ResourceTelemetryProcessCategory,
96): "backend" | "electron" | "monitor" {
97 if (category === "resource-monitor") return "monitor";
98 if (category.startsWith("electron-")) return "electron";
99 return "backend";
100}
101
102function electronCategory(metric: DesktopElectronProcessMetric): ResourceTelemetryProcessCategory {
103 switch (metric.type) {
104 case "Browser":
105 return "electron-main";
106 case "Tab":
107 return "electron-renderer";
108 case "GPU":
109 return "electron-gpu";
110 default:
111 return "electron-utility";
112 }
113}
114
115function inferredElectronCategory(
116 process: ResourceMonitorProcessSample,
117): ResourceTelemetryProcessCategory {
118 const command = process.command.toLowerCase();
119 if (command.includes("--type=renderer")) return "electron-renderer";
120 if (command.includes("--type=gpu-process")) return "electron-gpu";
121 return "electron-utility";
122}
123
124function matchElectronMetric(
125 process: ResourceMonitorProcessSample,
126 metricsByPid: ReadonlyMap<number, DesktopElectronProcessMetric>,
127): DesktopElectronProcessMetric | undefined {
128 const metric = metricsByPid.get(process.pid);
129 if (!metric) return undefined;
130 return Math.abs(metric.creationTimeMs - process.startTimeMs) <= ELECTRON_IDENTITY_TOLERANCE_MS
131 ? metric
132 : undefined;
133}
134
135function syntheticNativeSample(
136 metric: DesktopElectronProcessMetric,
137 sampledAtMs: number,
138 previous: ProcessState | undefined,
139): ResourceMonitorProcessSample {
140 const cpuTimeMs =
141 metric.cumulativeCpuSeconds !== undefined
142 ? Math.max(0, Math.round(metric.cumulativeCpuSeconds * 1_000))
143 : previous
144 ? previous.process.cpuTimeMs +
145 Math.max(0, ((sampledAtMs - previous.sampledAtMs) * metric.cpuPercent) / 100)
146 : 0;
147 return {
148 pid: metric.pid,
149 ppid: 0,
150 startTimeMs: metric.creationTimeMs,
151 runTimeMs: Math.max(0, sampledAtMs - metric.creationTimeMs),
152 name: metric.name ?? metric.serviceName ?? metric.type,
153 command: metric.name ?? metric.serviceName ?? metric.type,
154 status: "Running",
155 cpuPercent: metric.cpuPercent,
156 cpuTimeMs,
157 residentBytes: metric.workingSetBytes,
158 virtualBytes: 0,
159 ioReadBytes: 0,
160 ioWriteBytes: 0,
161 ioSemantics: "storage",
162 };
163}
164
165function processDepths(
166 processes: ReadonlyArray<ResourceMonitorProcessSample>,
167 roots: ReadonlySet<number>,
168): ReadonlyMap<number, number> {
169 const childrenByParent = new Map<number, number[]>();
170 for (const process of processes) {
171 const children = childrenByParent.get(process.ppid) ?? [];
172 children.push(process.pid);
173 childrenByParent.set(process.ppid, children);
174 }
175
176 const depths = new Map<number, number>();
177 const queue = [...roots].map((pid) => ({ pid, depth: 0 }));
178 while (queue.length > 0) {
179 const current = queue.shift();
180 if (!current || depths.has(current.pid)) continue;
181 depths.set(current.pid, current.depth);
182 for (const childPid of childrenByParent.get(current.pid) ?? []) {
183 queue.push({ pid: childPid, depth: current.depth + 1 });
184 }
185 }
186 return depths;
187}
188
189function isElectronDescendant(
190 pid: number,
191 processesByPid: ReadonlyMap<number, ResourceMonitorProcessSample>,
192 electronPids: ReadonlySet<number>,
193): boolean {
194 const visited = new Set<number>();
195 let currentPid = pid;
196 while (!visited.has(currentPid)) {
197 visited.add(currentPid);
198 if (electronPids.has(currentPid)) return true;
199 const current = processesByPid.get(currentPid);
200 if (!current || current.ppid <= 0 || current.ppid === currentPid) return false;
201 currentPid = current.ppid;
202 }
203 return false;
204}
205
206function hasElectronAncestor(
207 process: ResourceMonitorProcessSample,
208 processesByPid: ReadonlyMap<number, ResourceMonitorProcessSample>,
209 electronPids: ReadonlySet<number>,
210): boolean {
211 const visited = new Set<number>();
212 let currentPid = process.ppid;
213 while (currentPid > 0 && !visited.has(currentPid)) {
214 visited.add(currentPid);
215 if (electronPids.has(currentPid)) return true;
216 const current = processesByPid.get(currentPid);
217 if (!current || current.ppid === currentPid) return false;
218 currentPid = current.ppid;
219 }
220 return false;
221}
222
223function orderProcessTree(
224 processes: ReadonlyArray<ResourceTelemetryProcess>,
225 rootPids: ReadonlyArray<number>,
226): ReadonlyArray<ResourceTelemetryProcess> {
227 const processesByPid = new Map(processes.map((process) => [process.identity.pid, process]));
228 const childrenByParent = new Map<number, ResourceTelemetryProcess[]>();
229 for (const process of processes) {
230 const children = childrenByParent.get(process.ppid) ?? [];
231 children.push(process);
232 childrenByParent.set(process.ppid, children);
233 }
234 for (const children of childrenByParent.values()) {
235 children.sort((left, right) => left.identity.pid - right.identity.pid);
236 }
237
238 const ordered: ResourceTelemetryProcess[] = [];
239 const visited = new Set<number>();
240 const visit = (process: ResourceTelemetryProcess): void => {
241 if (visited.has(process.identity.pid)) return;
242 visited.add(process.identity.pid);
243 ordered.push(process);
244 for (const child of childrenByParent.get(process.identity.pid) ?? []) {
245 visit(child);
246 }
247 };
248
249 for (const rootPid of rootPids) {
250 const root = processesByPid.get(rootPid);
251 if (root) visit(root);
252 }
253 for (const process of processes.toSorted(
254 (left, right) => left.depth - right.depth || left.identity.pid - right.identity.pid,
255 )) {
256 visit(process);
257 }
258 return ordered;
259}
260
261function delta(input: {
262 readonly current: number;
263 readonly previous: number;
264 readonly elapsedMs: number;
265}): number {
266 if (
267 input.elapsedMs <= 0 ||
268 input.elapsedMs > MAX_DELTA_INTERVAL_MS ||
269 input.current < input.previous
270 ) {
271 return 0;
272 }
273 return input.current - input.previous;
274}
275
276function incrementCounters(counters: GroupCounters, update: Partial<GroupCounters>): GroupCounters {
277 return {
278 cpuTimeMs: counters.cpuTimeMs + (update.cpuTimeMs ?? 0),
279 ioReadBytes: counters.ioReadBytes + (update.ioReadBytes ?? 0),
280 ioWriteBytes: counters.ioWriteBytes + (update.ioWriteBytes ?? 0),
281 processStarts: counters.processStarts + (update.processStarts ?? 0),
282 processExits: counters.processExits + (update.processExits ?? 0),
283 };
284}
285
286function applyLifecycleCounters(input: {
287 readonly counters: TelemetryCounters;
288 readonly deltas: ReadonlyArray<ProcessDelta>;
289 readonly current: ReadonlyMap<string, ProcessState>;
290 readonly previous: ReadonlyMap<string, ProcessState>;
291}): TelemetryCounters {
292 let backend = input.counters.backend;
293 let electron = input.counters.electron;
294 let monitor = input.counters.monitor;
295 let allT3 = input.counters.allT3;
296 for (const processDelta of input.deltas) {
297 const group = categoryGroup(processDelta.category);
298 switch (group) {
299 case "backend":
300 backend = incrementCounters(backend, processDelta);
301 break;
302 case "electron":
303 electron = incrementCounters(electron, processDelta);
304 break;
305 case "monitor":
306 monitor = incrementCounters(monitor, processDelta);
307 break;
308 }
309 allT3 = incrementCounters(allT3, processDelta);
310 }
311
312 for (const [identityKey, current] of input.current) {
313 if (input.previous.has(identityKey)) continue;
314 const group = categoryGroup(current.process.category);
315 switch (group) {
316 case "backend":
317 backend = incrementCounters(backend, { processStarts: 1 });
318 break;
319 case "electron":
320 electron = incrementCounters(electron, { processStarts: 1 });
321 break;
322 case "monitor":
323 monitor = incrementCounters(monitor, { processStarts: 1 });
324 break;
325 }
326 allT3 = incrementCounters(allT3, { processStarts: 1 });
327 }
328
329 for (const [identityKey, previous] of input.previous) {
330 if (input.current.has(identityKey)) continue;
331 const group = categoryGroup(previous.process.category);
332 switch (group) {
333 case "backend":
334 backend = incrementCounters(backend, { processExits: 1 });
335 break;
336 case "electron":
337 electron = incrementCounters(electron, { processExits: 1 });
338 break;
339 case "monitor":
340 monitor = incrementCounters(monitor, { processExits: 1 });
341 break;
342 }
343 allT3 = incrementCounters(allT3, { processExits: 1 });
344 }
345
346 return { backend, electron, monitor, allT3 };
347}
348
349function aggregate(
350 processes: ReadonlyArray<ResourceTelemetryProcess>,
351 counters: GroupCounters,
352): ResourceTelemetryAggregate {
353 return {
354 processCount: processes.length,
355 currentCpuPercent: processes.reduce((total, process) => total + process.cpuPercent, 0),
356 cpuTimeMs: counters.cpuTimeMs,
357 currentRssBytes: processes.reduce((total, process) => total + process.residentBytes, 0),
358 peakRssBytes: processes.reduce((total, process) => total + process.peakResidentBytes, 0),
359 ioReadBytes: counters.ioReadBytes,
360 ioWriteBytes: counters.ioWriteBytes,
361 ioReadBytesPerSecond: processes.reduce(
362 (total, process) => total + process.ioReadBytesPerSecond,
363 0,
364 ),
365 ioWriteBytesPerSecond: processes.reduce(
366 (total, process) => total + process.ioWriteBytesPerSecond,
367 0,
368 ),
369 processStarts: counters.processStarts,
370 processExits: counters.processExits,
371 };
372}
373
374export function mergeProcesses(input: MergeProcessesInput): MergeProcessesResult {
375 const nativeProcesses = Option.match(input.nativeSnapshot, {
376 onNone: () => [] as ReadonlyArray<ResourceMonitorProcessSample>,
377 onSome: (snapshot) => snapshot.processes,
378 });
379 const electronMetrics = Option.match(input.desktopSnapshot, {
380 onNone: () => [] as ReadonlyArray<DesktopElectronProcessMetric>,
381 onSome: (snapshot) => snapshot.electronProcesses,
382 });
383 const sampledAtMs = Option.match(input.nativeSnapshot, {
384 onNone: () =>
385 Option.match(input.desktopSnapshot, {
386 onNone: () => input.fallbackSampledAtMs,
387 onSome: (snapshot) => snapshot.sampledAtUnixMs,
388 }),
389 onSome: (native) =>
390 Option.match(input.desktopSnapshot, {
391 onNone: () => native.sampledAtUnixMs,
392 onSome: (desktop) => Math.max(native.sampledAtUnixMs, desktop.sampledAtUnixMs),
393 }),
394 });
395 const nativeSampledAtMs = Option.map(
396 input.nativeSnapshot,
397 (snapshot) => snapshot.sampledAtUnixMs,
398 );
399 const desktopSampledAtMs = Option.map(
400 input.desktopSnapshot,
401 (snapshot) => snapshot.sampledAtUnixMs,
402 );
403 const nativeProcessPids = new Set(nativeProcesses.map((process) => process.pid));
404 const nativeByPid = new Map(nativeProcesses.map((process) => [process.pid, process]));
405 const metricsByPid = new Map<number, DesktopElectronProcessMetric>();
406 for (const metric of electronMetrics) {
407 const nativeProcess = nativeByPid.get(metric.pid);
408 if (!nativeProcess) {
409 nativeByPid.set(
410 metric.pid,
411 syntheticNativeSample(
412 metric,
413 Option.getOrElse(desktopSampledAtMs, () => sampledAtMs),
414 input.previous.get(processIdentityKey(metric.pid, metric.creationTimeMs)),
415 ),
416 );
417 metricsByPid.set(metric.pid, metric);
418 continue;
419 }
420 if (
421 Math.abs(metric.creationTimeMs - nativeProcess.startTimeMs) <= ELECTRON_IDENTITY_TOLERANCE_MS
422 ) {
423 metricsByPid.set(metric.pid, metric);
424 }
425 }
426 const processes = [...nativeByPid.values()];
427 const processesByPid = new Map(processes.map((process) => [process.pid, process]));
428 const requestedElectronRootPids = input.electronRootPids ?? new Set<number>();
429 const explicitElectronRootPids = new Set(
430 [...requestedElectronRootPids].filter((pid) => {
431 const process = processesByPid.get(pid);
432 if (!process) return false;
433 const expectedStartTime = input.electronRootStartTimes?.get(pid);
434 if (expectedStartTime !== undefined) {
435 return Math.abs(process.startTimeMs - expectedStartTime) <= ELECTRON_IDENTITY_TOLERANCE_MS;
436 }
437 if (metricsByPid.has(pid)) return true;
438 return [...input.previous.values()].some(
439 (previous) =>
440 previous.process.category === "electron-main" &&
441 previous.process.identity.pid === pid &&
442 previous.process.identity.startTimeMs === process.startTimeMs,
443 );
444 }),
445 );
446 const electronPids = new Set([...metricsByPid.keys(), ...explicitElectronRootPids]);
447 const electronRootPids = [
448 ...explicitElectronRootPids,
449 ...[...electronPids]
450 .filter((pid) => {
451 if (explicitElectronRootPids.has(pid)) return false;
452 const process = processesByPid.get(pid);
453 return process === undefined
454 ? true
455 : !hasElectronAncestor(process, processesByPid, electronPids);
456 })
457 .toSorted((left, right) => left - right),
458 ].filter((pid, index, values) => values.indexOf(pid) === index);
459 const rootPids = [input.serverPid, ...electronRootPids];
460 const roots = new Set(rootPids);
461 const depths = processDepths(processes, roots);
462 const childrenByParent = new Map<number, number[]>();
463 for (const process of processes) {
464 const children = childrenByParent.get(process.ppid) ?? [];
465 children.push(process.pid);
466 childrenByParent.set(process.ppid, children);
467 }
468
469 const nextPrevious = new Map<string, ProcessState>();
470 const processDeltas: ProcessDelta[] = [];
471 const normalized = processes.map((process): ResourceTelemetryProcess => {
472 const identityKey = processIdentityKey(process.pid, process.startTimeMs);
473 const previous = input.previous.get(identityKey);
474 const counterSampledAtMs = nativeProcessPids.has(process.pid)
475 ? Option.getOrElse(nativeSampledAtMs, () => sampledAtMs)
476 : Option.getOrElse(desktopSampledAtMs, () => sampledAtMs);
477 const elapsedMs = previous ? counterSampledAtMs - previous.sampledAtMs : 0;
478 const cpuTimeDelta = previous
479 ? delta({
480 current: process.cpuTimeMs,
481 previous: previous.process.cpuTimeMs,
482 elapsedMs,
483 })
484 : 0;
485 const ioReadDelta = previous
486 ? delta({
487 current: process.ioReadBytes,
488 previous: previous.process.ioReadBytes,
489 elapsedMs,
490 })
491 : 0;
492 const ioWriteDelta = previous
493 ? delta({
494 current: process.ioWriteBytes,
495 previous: previous.process.ioWriteBytes,
496 elapsedMs,
497 })
498 : 0;
499 const electronMetric = matchElectronMetric(process, metricsByPid);
500 const category: ResourceTelemetryProcessCategory =
501 process.pid === input.serverPid
502 ? "server"
503 : Option.contains(input.sidecarPid, process.pid)
504 ? "resource-monitor"
505 : explicitElectronRootPids.has(process.pid)
506 ? "electron-main"
507 : electronMetric
508 ? electronCategory(electronMetric)
509 : isElectronDescendant(process.pid, processesByPid, electronPids)
510 ? inferredElectronCategory(process)
511 : "server-child";
512 const firstSeenAt = previous?.process.firstSeenAt ?? DateTime.makeUnsafe(sampledAtMs);
513 const preservePreviousRates = !input.updatePrevious && previous !== undefined;
514 const cpuPercent = preservePreviousRates
515 ? previous.process.cpuPercent
516 : previous && elapsedMs > 0 && elapsedMs <= MAX_DELTA_INTERVAL_MS
517 ? (cpuTimeDelta / elapsedMs) * 100
518 : finiteNonNegative(process.cpuPercent);
519 const normalizedProcess: ResourceTelemetryProcess = {
520 identity: {
521 pid: process.pid,
522 startTimeMs: process.startTimeMs,
523 },
524 ppid: process.ppid,
525 childPids: [...(childrenByParent.get(process.pid) ?? [])].toSorted(
526 (left, right) => left - right,
527 ),
528 depth: depths.get(process.pid) ?? 0,
529 name: process.name,
530 command: process.command,
531 status: process.status,
532 category,
533 ...(electronMetric ? { electronType: electronMetric.type } : {}),
534 ...(electronMetric?.serviceName ? { electronServiceName: electronMetric.serviceName } : {}),
535 cpuPercent: finiteNonNegative(cpuPercent),
536 cpuTimeMs: process.cpuTimeMs,
537 residentBytes: process.residentBytes,
538 peakResidentBytes: Math.max(
539 process.residentBytes,
540 electronMetric?.peakWorkingSetBytes ?? 0,
541 previous?.process.peakResidentBytes ?? 0,
542 ),
543 virtualBytes: process.virtualBytes,
544 ioReadBytes: process.ioReadBytes,
545 ioWriteBytes: process.ioWriteBytes,
546 ioReadBytesPerSecond: preservePreviousRates
547 ? previous.process.ioReadBytesPerSecond
548 : elapsedMs > 0
549 ? finiteNonNegative((ioReadDelta * 1_000) / elapsedMs)
550 : 0,
551 ioWriteBytesPerSecond: preservePreviousRates
552 ? previous.process.ioWriteBytesPerSecond
553 : elapsedMs > 0
554 ? finiteNonNegative((ioWriteDelta * 1_000) / elapsedMs)
555 : 0,
556 ioSemantics: process.ioSemantics,
557 ...(electronMetric ? { idleWakeupsPerSecond: electronMetric.idleWakeupsPerSecond } : {}),
558 runTimeMs: process.runTimeMs,
559 firstSeenAt,
560 lastSeenAt: DateTime.makeUnsafe(sampledAtMs),
561 };
562 nextPrevious.set(identityKey, {
563 process: normalizedProcess,
564 sampledAtMs: counterSampledAtMs,
565 });
566 processDeltas.push({
567 identityKey,
568 category,
569 cpuTimeMs: cpuTimeDelta,
570 ioReadBytes: ioReadDelta,
571 ioWriteBytes: ioWriteDelta,
572 });
573 return normalizedProcess;
574 });
575 const ordered = orderProcessTree(normalized, rootPids);
576
577 const counters = input.updatePrevious
578 ? applyLifecycleCounters({
579 counters: input.counters,
580 deltas: processDeltas,
581 current: nextPrevious,
582 previous: input.previous,
583 })
584 : input.counters;
585 const backendProcesses = ordered.filter(
586 (process) => categoryGroup(process.category) === "backend",
587 );
588 const electronProcesses = ordered.filter(
589 (process) => categoryGroup(process.category) === "electron",
590 );
591 const monitorProcesses = ordered.filter(
592 (process) => categoryGroup(process.category) === "monitor",
593 );
594
595 return {
596 sampledAtMs,
597 processes: ordered,
598 previous: input.updatePrevious ? nextPrevious : input.previous,
599 counters,
600 groups: {
601 backend: aggregate(backendProcesses, counters.backend),
602 electron: aggregate(electronProcesses, counters.electron),
603 monitor: aggregate(monitorProcesses, counters.monitor),
604 allT3: aggregate(ordered, counters.allT3),
605 },
606 deltas: processDeltas,
607 };
608}
apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts created+241
......@@ -0,0 +1,241 @@
1import type { HostPowerSnapshot } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import * as Cause from "effect/Cause";
4import * as DateTime from "effect/DateTime";
5import * as Deferred from "effect/Deferred";
6import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
8import * as Ref from "effect/Ref";
9import * as Semaphore from "effect/Semaphore";
10
11import {
12 NativeTelemetryRequestTimedOut,
13 NativeTelemetryStreamClosed,
14 canCommandNativeTelemetrySidecar,
15 canRequestNativeTelemetryRetry,
16 commitCollectionControlUpdate,
17 nativeTelemetrySupervisorFailureMessage,
18 retainRecentNativeTelemetryFailures,
19 resolveNativeSampleIntervalMs,
20 synchronizeCollectionControlOnStart,
21} from "./NativeTelemetryClient.ts";
22
23const basePower: HostPowerSnapshot = {
24 source: "electron-main",
25 idle: "false",
26 idleSeconds: 0,
27 locked: "false",
28 suspended: false,
29 onBattery: "false",
30 lowPowerMode: "false",
31 thermalState: "nominal",
32 stale: false,
33 updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:00.000Z"),
34};
35
36describe("resolveNativeSampleIntervalMs", () => {
37 it("keeps a recovery cadence while suspended and backs off under host constraints", () => {
38 expect(resolveNativeSampleIntervalMs({ ...basePower, suspended: true }, 1)).toBe(15_000);
39 expect(resolveNativeSampleIntervalMs({ ...basePower, locked: "true" }, 1)).toBe(15_000);
40 expect(resolveNativeSampleIntervalMs({ ...basePower, lowPowerMode: "true" }, 1)).toBe(15_000);
41 expect(resolveNativeSampleIntervalMs({ ...basePower, thermalState: "critical" }, 1)).toBe(
42 15_000,
43 );
44 expect(resolveNativeSampleIntervalMs({ ...basePower, onBattery: "true" }, 1)).toBe(5_000);
45 });
46
47 it("keeps unknown background telemetry cheap but serves live diagnostics at 1Hz", () => {
48 const unknown: HostPowerSnapshot = {
49 ...basePower,
50 source: "unknown",
51 stale: true,
52 };
53 expect(resolveNativeSampleIntervalMs(unknown, 0)).toBe(5_000);
54 expect(resolveNativeSampleIntervalMs(unknown, 1)).toBe(1_000);
55 expect(
56 resolveNativeSampleIntervalMs(
57 { ...basePower, stale: true, locked: "true", suspended: true },
58 0,
59 ),
60 ).toBe(5_000);
61 expect(resolveNativeSampleIntervalMs(basePower, 0)).toBe(1_000);
62 });
63});
64
65describe("canRequestNativeTelemetryRetry", () => {
66 it("only accepts retry while the supervisor is waiting without a live sidecar", () => {
67 expect(canRequestNativeTelemetryRetry("degraded", false)).toBe(true);
68 expect(canRequestNativeTelemetryRetry("unavailable", false)).toBe(true);
69 expect(canRequestNativeTelemetryRetry("degraded", true)).toBe(false);
70 expect(canRequestNativeTelemetryRetry("healthy", false)).toBe(false);
71 expect(canRequestNativeTelemetryRetry("starting", false)).toBe(false);
72 });
73});
74
75describe("canCommandNativeTelemetrySidecar", () => {
76 it("keeps on-demand recovery commands available while a live sidecar is degraded", () => {
77 expect(canCommandNativeTelemetrySidecar("healthy", true)).toBe(true);
78 expect(canCommandNativeTelemetrySidecar("degraded", true)).toBe(true);
79 expect(canCommandNativeTelemetrySidecar("unavailable", true)).toBe(false);
80 expect(canCommandNativeTelemetrySidecar("degraded", false)).toBe(false);
81 });
82});
83
84describe("NativeTelemetryRequestTimedOut", () => {
85 it("models history and sample request deadlines without a fabricated cause", () => {
86 const historyTimeout = new NativeTelemetryRequestTimedOut({
87 operation: "readHistory",
88 timeoutMs: 15_000,
89 });
90 const sampleTimeout = new NativeTelemetryRequestTimedOut({
91 operation: "sampleNow",
92 timeoutMs: 5_000,
93 });
94
95 expect(historyTimeout.message).toBe(
96 "Resource monitor 'readHistory' request timed out after 15000ms.",
97 );
98 expect(sampleTimeout.message).toBe(
99 "Resource monitor 'sampleNow' request timed out after 5000ms.",
100 );
101 expect("cause" in historyTimeout).toBe(false);
102 expect("cause" in sampleTimeout).toBe(false);
103 });
104});
105
106describe("native telemetry supervisor failures", () => {
107 it("distinguishes a closed event stream from a process exit", () => {
108 expect(new NativeTelemetryStreamClosed().message).toBe(
109 "Resource monitor event stream closed unexpectedly.",
110 );
111 });
112
113 it("keeps defect details out of the caller-visible health message", () => {
114 const secret = "credential=do-not-expose";
115 const message = nativeTelemetrySupervisorFailureMessage(Cause.die(new Error(secret)));
116
117 expect(message).toBe("Resource monitor supervisor stopped unexpectedly.");
118 expect(message).not.toContain(secret);
119 });
120});
121
122describe("retainRecentNativeTelemetryFailures", () => {
123 it("expires old failures so an isolated crash restarts from the initial backoff", () => {
124 expect(retainRecentNativeTelemetryFailures([0, 30_000], 90_001)).toEqual([]);
125 expect(retainRecentNativeTelemetryFailures([30_000, 60_000], 90_000)).toEqual([30_000, 60_000]);
126 });
127});
128
129describe("commitCollectionControlUpdate", () => {
130 it.effect("retains desired demand and retries unapplied sidecar state", () =>
131 Effect.gen(function* () {
132 const initial = {
133 hostPower: basePower,
134 liveSubscriberCount: 0,
135 sampleIntervalMs: 5_000,
136 };
137 const desired = yield* Ref.make(initial);
138 const applied = yield* Ref.make(initial);
139 const failure = new Error("sidecar write failed");
140 const receivedStates: Array<readonly [number, number]> = [];
141
142 const received = yield* commitCollectionControlUpdate(
143 desired,
144 applied,
145 (current) => ({
146 ...current,
147 liveSubscriberCount: 1,
148 sampleIntervalMs: 1_000,
149 }),
150 (previous, next) => {
151 receivedStates.push([previous.sampleIntervalMs, next.sampleIntervalMs]);
152 return Effect.fail(failure);
153 },
154 ).pipe(Effect.flip);
155
156 expect(received).toBe(failure);
157 expect(yield* Ref.get(desired)).toEqual({
158 ...initial,
159 liveSubscriberCount: 1,
160 sampleIntervalMs: 1_000,
161 });
162 expect(yield* Ref.get(applied)).toEqual(initial);
163
164 yield* commitCollectionControlUpdate(
165 desired,
166 applied,
167 (current) => current,
168 (previous, next) => {
169 receivedStates.push([previous.sampleIntervalMs, next.sampleIntervalMs]);
170 return Effect.void;
171 },
172 );
173 expect(receivedStates).toEqual([
174 [5_000, 1_000],
175 [5_000, 1_000],
176 ]);
177 expect(yield* Ref.get(applied)).toEqual(yield* Ref.get(desired));
178 }),
179 );
180
181 it.effect("serializes startup synchronization with runtime control updates", () =>
182 Effect.gen(function* () {
183 const initial = {
184 hostPower: basePower,
185 liveSubscriberCount: 0,
186 sampleIntervalMs: 5_000,
187 };
188 const desired = yield* Ref.make(initial);
189 const applied = yield* Ref.make(initial);
190 const ready = yield* Ref.make(false);
191 const mutex = yield* Semaphore.make(1);
192 const startupApplying = yield* Deferred.make<void>();
193 const releaseStartup = yield* Deferred.make<void>();
194 const appliedIntervals: Array<number> = [];
195
196 const startupFiber = yield* synchronizeCollectionControlOnStart(
197 mutex,
198 desired,
199 applied,
200 (control) =>
201 Effect.sync(() => {
202 appliedIntervals.push(control.sampleIntervalMs);
203 }).pipe(
204 Effect.andThen(Deferred.succeed(startupApplying, undefined)),
205 Effect.andThen(Deferred.await(releaseStartup)),
206 Effect.asVoid,
207 ),
208 Ref.set(ready, true),
209 ).pipe(Effect.forkChild);
210 yield* Deferred.await(startupApplying);
211
212 const updateFiber = yield* mutex
213 .withPermits(1)(
214 commitCollectionControlUpdate(
215 desired,
216 applied,
217 (current) => ({
218 ...current,
219 liveSubscriberCount: 1,
220 sampleIntervalMs: 1_000,
221 }),
222 (_previous, next) =>
223 Effect.gen(function* () {
224 expect(yield* Ref.get(ready)).toBe(true);
225 appliedIntervals.push(next.sampleIntervalMs);
226 }),
227 ),
228 )
229 .pipe(Effect.forkChild);
230 yield* Effect.yieldNow;
231 expect(yield* Ref.get(desired)).toEqual(initial);
232
233 yield* Deferred.succeed(releaseStartup, undefined);
234 yield* Fiber.join(startupFiber);
235 yield* Fiber.join(updateFiber);
236
237 expect(appliedIntervals).toEqual([5_000, 1_000]);
238 expect(yield* Ref.get(applied)).toEqual(yield* Ref.get(desired));
239 }),
240 );
241});
apps/server/src/resourceTelemetry/NativeTelemetryClient.ts created+1024
......@@ -0,0 +1,1024 @@
1import type {
2 HostPowerSnapshot,
3 ResourceMonitorCapabilities,
4 ResourceMonitorCommand,
5 ResourceMonitorEvent,
6 ResourceMonitorExternalProcess,
7 ResourceMonitorHelloEvent,
8 ResourceMonitorSnapshotEvent,
9 ResourceTelemetrySourceStatus,
10} from "@t3tools/contracts";
11import {
12 RESOURCE_MONITOR_PROTOCOL_VERSION,
13 ResourceMonitorCommand as ResourceMonitorCommandSchema,
14 ResourceMonitorEvent as ResourceMonitorEventSchema,
15} from "@t3tools/contracts";
16import * as Cause from "effect/Cause";
17import * as Context from "effect/Context";
18import * as Crypto from "effect/Crypto";
19import * as DateTime from "effect/DateTime";
20import * as Deferred from "effect/Deferred";
21import * as Duration from "effect/Duration";
22import * as Effect from "effect/Effect";
23import * as Fiber from "effect/Fiber";
24import * as Layer from "effect/Layer";
25import * as Option from "effect/Option";
26import * as PubSub from "effect/PubSub";
27import * as Queue from "effect/Queue";
28import * as Ref from "effect/Ref";
29import * as Result from "effect/Result";
30import * as Schema from "effect/Schema";
31import * as Semaphore from "effect/Semaphore";
32import * as Scope from "effect/Scope";
33import * as Stream from "effect/Stream";
34import * as Ndjson from "effect/unstable/encoding/Ndjson";
35import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
36
37import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts";
38import { ServerConfig } from "../config.ts";
39import { subscribeBeforeSnapshotWithoutMutex } from "../utils/subscribeBeforeSnapshot.ts";
40
41const SAMPLE_INTERVAL_MS = 1_000;
42const UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS = 5_000;
43const BATTERY_SAMPLE_INTERVAL_MS = 5_000;
44const CONSTRAINED_SAMPLE_INTERVAL_MS = 15_000;
45const HANDSHAKE_TIMEOUT = Duration.seconds(5);
46const SAMPLE_REQUEST_TIMEOUT = Duration.seconds(5);
47const HISTORY_REQUEST_TIMEOUT = Duration.seconds(15);
48const INITIAL_RESTART_DELAY = Duration.millis(500);
49const MAX_RESTART_DELAY = Duration.seconds(10);
50const FAILURE_WINDOW_MS = 60_000;
51const MAX_FAILURES_PER_WINDOW = 5;
52
53export class NativeTelemetrySpawnFailed extends Schema.TaggedErrorClass<NativeTelemetrySpawnFailed>()(
54 "NativeTelemetrySpawnFailed",
55 {
56 path: Schema.String,
57 cause: Schema.Defect(),
58 },
59) {
60 override get message(): string {
61 return `Failed to start resource monitor '${this.path}'.`;
62 }
63}
64
65export class NativeTelemetryHandshakeTimedOut extends Schema.TaggedErrorClass<NativeTelemetryHandshakeTimedOut>()(
66 "NativeTelemetryHandshakeTimedOut",
67 {
68 timeoutMs: Schema.Number,
69 },
70) {
71 override get message(): string {
72 return `Resource monitor handshake timed out after ${this.timeoutMs}ms.`;
73 }
74}
75
76export class NativeTelemetryRequestTimedOut extends Schema.TaggedErrorClass<NativeTelemetryRequestTimedOut>()(
77 "NativeTelemetryRequestTimedOut",
78 {
79 operation: Schema.Literals(["readHistory", "sampleNow"]),
80 timeoutMs: Schema.Number,
81 },
82) {
83 override get message(): string {
84 return `Resource monitor '${this.operation}' request timed out after ${this.timeoutMs}ms.`;
85 }
86}
87
88export class NativeTelemetryProtocolMismatch extends Schema.TaggedErrorClass<NativeTelemetryProtocolMismatch>()(
89 "NativeTelemetryProtocolMismatch",
90 {
91 expectedVersion: Schema.Number,
92 receivedVersion: Schema.Number,
93 },
94) {
95 override get message(): string {
96 return `Resource monitor protocol ${this.receivedVersion} is incompatible with expected protocol ${this.expectedVersion}.`;
97 }
98}
99
100export class NativeTelemetryDecodeFailed extends Schema.TaggedErrorClass<NativeTelemetryDecodeFailed>()(
101 "NativeTelemetryDecodeFailed",
102 {
103 cause: Schema.Defect(),
104 },
105) {
106 override get message(): string {
107 return "Failed to decode resource monitor output.";
108 }
109}
110
111export class NativeTelemetryCommandFailed extends Schema.TaggedErrorClass<NativeTelemetryCommandFailed>()(
112 "NativeTelemetryCommandFailed",
113 {
114 operation: Schema.String,
115 cause: Schema.Defect(),
116 },
117) {
118 override get message(): string {
119 return `Resource monitor command '${this.operation}' failed.`;
120 }
121}
122
123export class NativeTelemetryExited extends Schema.TaggedErrorClass<NativeTelemetryExited>()(
124 "NativeTelemetryExited",
125 {
126 exitCode: Schema.Number,
127 },
128) {
129 override get message(): string {
130 return `Resource monitor exited with code ${this.exitCode}.`;
131 }
132}
133
134export class NativeTelemetryStreamClosed extends Schema.TaggedErrorClass<NativeTelemetryStreamClosed>()(
135 "NativeTelemetryStreamClosed",
136 {},
137) {
138 override get message(): string {
139 return "Resource monitor event stream closed unexpectedly.";
140 }
141}
142
143export class NativeTelemetryUnavailable extends Schema.TaggedErrorClass<NativeTelemetryUnavailable>()(
144 "NativeTelemetryUnavailable",
145 {
146 reason: Schema.String,
147 },
148) {
149 override get message(): string {
150 return `Resource monitor is unavailable: ${this.reason}`;
151 }
152}
153
154export type NativeTelemetryClientError =
155 | ResourceMonitorBinary.ResourceMonitorBinaryError
156 | NativeTelemetrySpawnFailed
157 | NativeTelemetryHandshakeTimedOut
158 | NativeTelemetryRequestTimedOut
159 | NativeTelemetryProtocolMismatch
160 | NativeTelemetryDecodeFailed
161 | NativeTelemetryCommandFailed
162 | NativeTelemetryExited
163 | NativeTelemetryStreamClosed
164 | NativeTelemetryUnavailable;
165
166export interface NativeTelemetryClientHealth {
167 readonly status: ResourceTelemetrySourceStatus;
168 readonly hello: Option.Option<ResourceMonitorHelloEvent>;
169 readonly lastSampleAt: Option.Option<DateTime.Utc>;
170 readonly lastError: Option.Option<string>;
171 readonly restartCount: number;
172 readonly sampleIntervalMs: number;
173}
174
175export interface NativeTelemetrySnapshot {
176 readonly generation: number;
177 readonly snapshot: ResourceMonitorSnapshotEvent;
178}
179
180export class NativeTelemetryClient extends Context.Service<
181 NativeTelemetryClient,
182 {
183 readonly capabilities: Effect.Effect<ResourceMonitorCapabilities, NativeTelemetryClientError>;
184 readonly snapshots: Stream.Stream<NativeTelemetrySnapshot, NativeTelemetryClientError>;
185 readonly readHistory: (
186 windowMs: number,
187 ) => Effect.Effect<ReadonlyArray<ResourceMonitorSnapshotEvent>, NativeTelemetryClientError>;
188 readonly setExternalProcesses: (
189 processes: ReadonlyArray<ResourceMonitorExternalProcess>,
190 ) => Effect.Effect<void, NativeTelemetryClientError>;
191 readonly setHostPowerState: (
192 snapshot: HostPowerSnapshot,
193 ) => Effect.Effect<void, NativeTelemetryClientError>;
194 readonly sampleNow: Effect.Effect<NativeTelemetrySnapshot, NativeTelemetryClientError>;
195 readonly retry: Effect.Effect<boolean>;
196 readonly health: Effect.Effect<NativeTelemetryClientHealth>;
197 readonly subscribeHealth: Effect.Effect<
198 {
199 readonly latest: NativeTelemetryClientHealth;
200 readonly changes: Stream.Stream<NativeTelemetryClientHealth>;
201 },
202 never,
203 Scope.Scope
204 >;
205 }
206>()("t3/resourceTelemetry/NativeTelemetryClient") {}
207
208interface ClientState {
209 readonly status: ResourceTelemetrySourceStatus;
210 readonly handle: Option.Option<ChildProcessSpawner.ChildProcessHandle>;
211 readonly hello: Option.Option<ResourceMonitorHelloEvent>;
212 readonly lastSampleAt: Option.Option<DateTime.Utc>;
213 readonly lastError: Option.Option<string>;
214 readonly restartCount: number;
215}
216
217export interface CollectionControl {
218 readonly hostPower: HostPowerSnapshot;
219 readonly liveSubscriberCount: number;
220 readonly sampleIntervalMs: number;
221}
222
223interface PendingHistoryRequest {
224 readonly deferred: Deferred.Deferred<
225 ReadonlyArray<ResourceMonitorSnapshotEvent>,
226 NativeTelemetryClientError
227 >;
228 readonly snapshots: ReadonlyArray<ResourceMonitorSnapshotEvent>;
229}
230
231const initialState: ClientState = {
232 status: "starting",
233 handle: Option.none(),
234 hello: Option.none(),
235 lastSampleAt: Option.none(),
236 lastError: Option.none(),
237 restartCount: 0,
238};
239
240function toHealth(state: ClientState, sampleIntervalMs: number): NativeTelemetryClientHealth {
241 return {
242 status: state.status,
243 hello: state.hello,
244 lastSampleAt: state.lastSampleAt,
245 lastError: state.lastError,
246 restartCount: state.restartCount,
247 sampleIntervalMs,
248 };
249}
250
251function isThermallyConstrained(snapshot: HostPowerSnapshot): boolean {
252 return snapshot.thermalState === "serious" || snapshot.thermalState === "critical";
253}
254
255export function resolveNativeSampleIntervalMs(
256 snapshot: HostPowerSnapshot,
257 liveSubscriberCount: number,
258): number {
259 if (snapshot.stale || snapshot.source === "unknown") {
260 return liveSubscriberCount > 0 ? SAMPLE_INTERVAL_MS : UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS;
261 }
262 if (
263 snapshot.suspended ||
264 snapshot.locked === "true" ||
265 snapshot.lowPowerMode === "true" ||
266 isThermallyConstrained(snapshot)
267 ) {
268 return CONSTRAINED_SAMPLE_INTERVAL_MS;
269 }
270 if (snapshot.onBattery === "true") return BATTERY_SAMPLE_INTERVAL_MS;
271 return SAMPLE_INTERVAL_MS;
272}
273
274export function commitCollectionControlUpdate<E, R>(
275 desiredState: Ref.Ref<CollectionControl>,
276 appliedState: Ref.Ref<CollectionControl>,
277 update: (current: CollectionControl) => CollectionControl,
278 apply: (previous: CollectionControl, next: CollectionControl) => Effect.Effect<void, E, R>,
279): Effect.Effect<readonly [CollectionControl, CollectionControl], E, R> {
280 return Effect.gen(function* () {
281 const [previousDesired, next] = yield* Ref.modify(desiredState, (previous) => {
282 const next = update(previous);
283 return [[previous, next] as const, next];
284 });
285 const previousApplied = yield* Ref.get(appliedState);
286 yield* apply(previousApplied, next);
287 yield* Ref.set(appliedState, next);
288 return [previousDesired, next] as const;
289 });
290}
291
292export function synchronizeCollectionControlOnStart<E1, R1, E2, R2>(
293 mutex: Semaphore.Semaphore,
294 desiredState: Ref.Ref<CollectionControl>,
295 appliedState: Ref.Ref<CollectionControl>,
296 apply: (control: CollectionControl) => Effect.Effect<void, E1, R1>,
297 markReady: Effect.Effect<void, E2, R2>,
298) {
299 return mutex.withPermits(1)(
300 Effect.gen(function* () {
301 const control = yield* Ref.get(desiredState);
302 yield* apply(control);
303 yield* Ref.set(appliedState, control);
304 yield* markReady;
305 return control;
306 }),
307 );
308}
309
310const decodeMonitorEvent: (
311 value: unknown,
312) => Effect.Effect<ResourceMonitorEvent, Schema.SchemaError> = Schema.decodeUnknownEffect(
313 ResourceMonitorEventSchema,
314);
315const encodeMonitorCommand = Schema.encodeEffect(
316 Schema.fromJsonString(ResourceMonitorCommandSchema),
317);
318const isProtocolMismatch = Schema.is(NativeTelemetryProtocolMismatch);
319const isDecodeFailed = Schema.is(NativeTelemetryDecodeFailed);
320const isCommandFailed = Schema.is(NativeTelemetryCommandFailed);
321
322function eventVersion(value: unknown): number | undefined {
323 if (typeof value !== "object" || value === null) return undefined;
324 const version = Reflect.get(value, "version");
325 return typeof version === "number" ? version : undefined;
326}
327
328function restartDelay(attempt: number): Duration.Duration {
329 return Duration.min(Duration.times(INITIAL_RESTART_DELAY, 2 ** attempt), MAX_RESTART_DELAY);
330}
331
332export function retainRecentNativeTelemetryFailures(
333 failures: ReadonlyArray<number>,
334 now: number,
335): ReadonlyArray<number> {
336 return failures.filter((failedAt) => now - failedAt <= FAILURE_WINDOW_MS);
337}
338
339function errorMessage(error: NativeTelemetryClientError): string {
340 return error.message;
341}
342
343export function nativeTelemetrySupervisorFailureMessage(_cause: Cause.Cause<unknown>): string {
344 return "Resource monitor supervisor stopped unexpectedly.";
345}
346
347export function canRequestNativeTelemetryRetry(
348 status: ResourceTelemetrySourceStatus,
349 hasHandle: boolean,
350): boolean {
351 return status !== "healthy" && status !== "starting" && !hasHandle;
352}
353
354export function canCommandNativeTelemetrySidecar(
355 status: ResourceTelemetrySourceStatus,
356 hasHandle: boolean,
357): boolean {
358 return hasHandle && (status === "healthy" || status === "degraded");
359}
360
361export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(function* () {
362 const binary = yield* ResourceMonitorBinary.ResourceMonitorBinary;
363 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
364 const crypto = yield* Crypto.Crypto;
365 const config = yield* ServerConfig;
366 const initializedAt = yield* DateTime.now;
367 const state = yield* Ref.make(initialState);
368 const collectionControl = yield* Ref.make<CollectionControl>({
369 hostPower: {
370 source: "unknown",
371 idle: "unknown",
372 idleSeconds: null,
373 locked: "unknown",
374 suspended: false,
375 onBattery: "unknown",
376 lowPowerMode: "unknown",
377 thermalState: "unknown",
378 stale: true,
379 updatedAt: initializedAt,
380 },
381 liveSubscriberCount: 0,
382 sampleIntervalMs: UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS,
383 });
384 const appliedCollectionControl = yield* Ref.make(yield* Ref.get(collectionControl));
385 const externalProcesses = yield* Ref.make<ReadonlyArray<ResourceMonitorExternalProcess>>([]);
386 const pendingSamples = yield* Ref.make(
387 new Map<string, Deferred.Deferred<NativeTelemetrySnapshot, NativeTelemetryClientError>>(),
388 );
389 const pendingHistories = yield* Ref.make(new Map<string, PendingHistoryRequest>());
390 const snapshots = yield* PubSub.sliding<NativeTelemetrySnapshot>(8);
391 const healthChanges = yield* PubSub.sliding<NativeTelemetryClientHealth>(4);
392 const retryQueue = yield* Queue.sliding<void>(1);
393 const commandMutex = yield* Semaphore.make(1);
394 const controlMutex = yield* Semaphore.make(1);
395 const currentHealth = Effect.all([Ref.get(state), Ref.get(collectionControl)]).pipe(
396 Effect.map(([current, control]) => toHealth(current, control.sampleIntervalMs)),
397 );
398 const publishHealth = currentHealth.pipe(
399 Effect.flatMap((health) => PubSub.publish(healthChanges, health)),
400 Effect.asVoid,
401 );
402
403 const failPending = (error: NativeTelemetryClientError) =>
404 Effect.gen(function* () {
405 const samples = yield* Ref.getAndSet(pendingSamples, new Map());
406 const histories = yield* Ref.getAndSet(pendingHistories, new Map());
407 yield* Effect.forEach(samples.values(), (deferred) => Deferred.fail(deferred, error), {
408 discard: true,
409 });
410 yield* Effect.forEach(
411 histories.values(),
412 (request) => Deferred.fail(request.deferred, error),
413 { discard: true },
414 );
415 });
416
417 const writeCommand = (
418 handle: ChildProcessSpawner.ChildProcessHandle,
419 command: ResourceMonitorCommand,
420 ): Effect.Effect<void, NativeTelemetryClientError> =>
421 commandMutex.withPermits(1)(
422 encodeMonitorCommand(command).pipe(
423 Effect.map((encoded) => `${encoded}\n`),
424 Effect.mapError(
425 (cause) =>
426 new NativeTelemetryCommandFailed({
427 operation: command.type,
428 cause,
429 }),
430 ),
431 Effect.flatMap((encoded) =>
432 Stream.run(Stream.encodeText(Stream.make(encoded)), handle.stdin),
433 ),
434 Effect.mapError(
435 (cause) =>
436 new NativeTelemetryCommandFailed({
437 operation: command.type,
438 cause,
439 }),
440 ),
441 ),
442 );
443
444 const processEvent = (
445 event: ResourceMonitorEvent,
446 helloDeferred: Deferred.Deferred<ResourceMonitorHelloEvent>,
447 generation: number,
448 ): Effect.Effect<void, NativeTelemetryClientError> => {
449 switch (event.type) {
450 case "hello":
451 return Ref.update(state, (current) => ({
452 ...current,
453 status: "starting" as const,
454 hello: Option.some(event),
455 lastError: Option.none(),
456 })).pipe(
457 Effect.andThen(publishHealth),
458 Effect.andThen(Deferred.succeed(helloDeferred, event)),
459 Effect.asVoid,
460 );
461 case "snapshot":
462 return Effect.gen(function* () {
463 const nativeSnapshot = { generation, snapshot: event } satisfies NativeTelemetrySnapshot;
464 const sampledAt = DateTime.makeUnsafe(event.sampledAtUnixMs);
465 yield* Ref.update(state, (current) => ({
466 ...current,
467 status: "healthy" as const,
468 lastSampleAt: Option.some(sampledAt),
469 lastError: Option.none(),
470 }));
471 yield* publishHealth;
472 yield* PubSub.publish(snapshots, nativeSnapshot);
473 if (event.requestId) {
474 const deferred = yield* Ref.modify(pendingSamples, (pending) => {
475 const next = new Map(pending);
476 const current = next.get(event.requestId!);
477 next.delete(event.requestId!);
478 return [Option.fromUndefinedOr(current), next];
479 });
480 if (Option.isSome(deferred)) {
481 yield* Deferred.succeed(deferred.value, nativeSnapshot);
482 }
483 }
484 });
485 case "historyChunk":
486 return Effect.gen(function* () {
487 const latestSnapshot = event.snapshots.at(-1);
488 yield* Ref.update(state, (current) => ({
489 ...current,
490 status: "healthy" as const,
491 lastSampleAt: latestSnapshot
492 ? Option.some(DateTime.makeUnsafe(latestSnapshot.sampledAtUnixMs))
493 : current.lastSampleAt,
494 lastError: Option.none(),
495 }));
496 yield* publishHealth;
497 const completed = yield* Ref.modify(pendingHistories, (pending) => {
498 const request = pending.get(event.requestId);
499 if (!request) return [Option.none(), pending] as const;
500 const snapshots = [...request.snapshots, ...event.snapshots];
501 const next = new Map(pending);
502 if (event.done) {
503 next.delete(event.requestId);
504 return [Option.some({ deferred: request.deferred, snapshots }), next] as const;
505 }
506 next.set(event.requestId, { deferred: request.deferred, snapshots });
507 return [Option.none(), next] as const;
508 });
509 if (Option.isSome(completed)) {
510 yield* Deferred.succeed(completed.value.deferred, completed.value.snapshots);
511 }
512 });
513 case "error":
514 return Ref.update(state, (current) => ({
515 ...current,
516 status: "degraded" as const,
517 lastError: Option.some(event.message),
518 })).pipe(
519 Effect.andThen(publishHealth),
520 Effect.andThen(
521 event.recoverable
522 ? Effect.void
523 : Effect.fail(
524 new NativeTelemetryCommandFailed({
525 operation: event.code,
526 cause: event.message,
527 }),
528 ),
529 ),
530 );
531 }
532 };
533
534 const runAttempt: Effect.Effect<void, NativeTelemetryClientError> = Effect.scoped(
535 Effect.gen(function* () {
536 const executablePath = yield* binary.resolve;
537 const command = ChildProcess.make(executablePath, [], {
538 cwd: config.cwd,
539 stdin: {
540 stream: "pipe",
541 endOnDone: false,
542 },
543 stdout: "pipe",
544 stderr: "pipe",
545 killSignal: "SIGTERM",
546 forceKillAfter: Duration.seconds(2),
547 });
548 const handle = yield* Effect.acquireRelease(
549 spawner
550 .spawn(command)
551 .pipe(
552 Effect.mapError(
553 (cause) => new NativeTelemetrySpawnFailed({ path: executablePath, cause }),
554 ),
555 ),
556 (child) => child.kill().pipe(Effect.ignore),
557 );
558 yield* Ref.update(state, (current) => ({
559 ...current,
560 status: "starting" as const,
561 handle: Option.some(handle),
562 hello: Option.none(),
563 }));
564 yield* publishHealth;
565 const generation = (yield* Ref.get(state)).restartCount;
566
567 const helloDeferred = yield* Deferred.make<ResourceMonitorHelloEvent>();
568 const eventFiber = yield* handle.stdout.pipe(
569 Stream.pipeThroughChannel(Ndjson.decode({ ignoreEmptyLines: true })),
570 Stream.mapEffect(
571 (
572 value,
573 ): Effect.Effect<
574 ResourceMonitorEvent,
575 NativeTelemetryProtocolMismatch | NativeTelemetryDecodeFailed
576 > => {
577 const version = eventVersion(value);
578 if (version !== undefined && version !== RESOURCE_MONITOR_PROTOCOL_VERSION) {
579 return Effect.fail(
580 new NativeTelemetryProtocolMismatch({
581 expectedVersion: RESOURCE_MONITOR_PROTOCOL_VERSION,
582 receivedVersion: version,
583 }),
584 );
585 }
586 return decodeMonitorEvent(value).pipe(
587 Effect.mapError((cause) => new NativeTelemetryDecodeFailed({ cause })),
588 );
589 },
590 ),
591 Stream.runForEach((event) => processEvent(event, helloDeferred, generation)),
592 Effect.mapError((cause) =>
593 isProtocolMismatch(cause) || isDecodeFailed(cause) || isCommandFailed(cause)
594 ? cause
595 : new NativeTelemetryDecodeFailed({ cause }),
596 ),
597 Effect.forkScoped,
598 );
599 yield* handle.stderr.pipe(Stream.runDrain, Effect.ignore, Effect.forkScoped);
600
601 const hello = yield* Deferred.await(helloDeferred).pipe(
602 Effect.timeoutOption(HANDSHAKE_TIMEOUT),
603 Effect.flatMap(
604 Option.match({
605 onNone: () =>
606 Effect.fail(
607 new NativeTelemetryHandshakeTimedOut({
608 timeoutMs: Duration.toMillis(HANDSHAKE_TIMEOUT),
609 }),
610 ),
611 onSome: Effect.succeed,
612 }),
613 ),
614 );
615 yield* synchronizeCollectionControlOnStart(
616 controlMutex,
617 collectionControl,
618 appliedCollectionControl,
619 (control) =>
620 Effect.gen(function* () {
621 yield* writeCommand(handle, {
622 version: RESOURCE_MONITOR_PROTOCOL_VERSION,
623 type: "configure",
624 rootPid: process.pid,
625 sampleIntervalMs: control.sampleIntervalMs,
626 externalProcesses: [...(yield* Ref.get(externalProcesses))],
627 });
628 if (control.liveSubscriberCount > 0) {
629 yield* writeCommand(handle, {
630 version: RESOURCE_MONITOR_PROTOCOL_VERSION,
631 type: "setStreaming",
632 enabled: true,
633 });
634 }
635 }),
636 Ref.update(state, (current) => ({
637 ...current,
638 status: "healthy" as const,
639 hello: Option.some(hello),
640 })).pipe(Effect.andThen(publishHealth)),
641 );
642
643 yield* writeCommand(handle, {
644 version: RESOURCE_MONITOR_PROTOCOL_VERSION,
645 type: "setExternalProcesses",
646 processes: [...(yield* Ref.get(externalProcesses))],
647 });
648
649 const exitEffect = handle.exitCode.pipe(
650 Effect.mapError(
651 (cause) =>
652 new NativeTelemetryCommandFailed({
653 operation: "waitForExit",
654 cause,
655 }),
656 ),
657 Effect.flatMap((exitCode) =>
658 Effect.fail(new NativeTelemetryExited({ exitCode: Number(exitCode) })),
659 ),
660 );
661 const decoderEffect = Fiber.join(eventFiber).pipe(
662 Effect.andThen(Effect.fail(new NativeTelemetryStreamClosed())),
663 );
664 return yield* Effect.raceFirst(exitEffect, decoderEffect);
665 }),
666 ).pipe(
667 Effect.ensuring(
668 Ref.update(state, (current) => ({
669 ...current,
670 handle: Option.none(),
671 })),
672 ),
673 );
674
675 yield* Effect.gen(function* () {
676 let failures: ReadonlyArray<number> = [];
677 let restartAttempt = 0;
678
679 while (true) {
680 const result = yield* Effect.result(runAttempt);
681 if (Result.isSuccess(result)) {
682 return;
683 }
684
685 const error = result.failure;
686 const now = DateTime.toEpochMillis(yield* DateTime.now);
687 const recentFailures = retainRecentNativeTelemetryFailures(failures, now);
688 if (recentFailures.length === 0) {
689 restartAttempt = 0;
690 }
691 failures = [...recentFailures, now];
692 const exhausted = failures.length >= MAX_FAILURES_PER_WINDOW;
693 yield* Ref.update(state, (current) => ({
694 ...current,
695 status: exhausted ? ("unavailable" as const) : ("degraded" as const),
696 hello: Option.none(),
697 lastError: Option.some(errorMessage(error)),
698 restartCount: current.restartCount + 1,
699 }));
700 yield* publishHealth;
701 yield* failPending(error);
702
703 if (exhausted) {
704 yield* Queue.take(retryQueue);
705 failures = [];
706 restartAttempt = 0;
707 yield* Ref.update(state, (current) => ({
708 ...current,
709 status: "starting" as const,
710 hello: Option.none(),
711 lastError: Option.none(),
712 }));
713 yield* publishHealth;
714 continue;
715 }
716
717 const manuallyRetried = yield* Effect.raceFirst(
718 Effect.sleep(restartDelay(restartAttempt)).pipe(Effect.as(false)),
719 Queue.take(retryQueue).pipe(Effect.as(true)),
720 );
721 restartAttempt = manuallyRetried ? 0 : restartAttempt + 1;
722 }
723 }).pipe(
724 Effect.catchCause((cause) =>
725 Cause.hasInterruptsOnly(cause)
726 ? Effect.void
727 : Ref.update(state, (current) => ({
728 ...current,
729 status: "unavailable" as const,
730 hello: Option.none(),
731 lastError: Option.some(nativeTelemetrySupervisorFailureMessage(cause)),
732 })).pipe(
733 Effect.andThen(publishHealth),
734 Effect.andThen(
735 Effect.logWarning("Resource monitor supervisor failed", {
736 cause: Cause.pretty(cause),
737 }),
738 ),
739 ),
740 ),
741 Effect.forkScoped,
742 );
743
744 const applyCollectionControl = Effect.fn(
745 "resourceTelemetry.nativeTelemetryClient.applyCollectionControl",
746 )(function* (previous: CollectionControl, next: CollectionControl) {
747 const current = yield* Ref.get(state);
748 if (canCommandNativeTelemetrySidecar(current.status, Option.isSome(current.handle))) {
749 const handle = Option.getOrThrow(current.handle);
750 if (previous.sampleIntervalMs !== next.sampleIntervalMs) {
751 yield* writeCommand(handle, {
752 version: RESOURCE_MONITOR_PROTOCOL_VERSION,
753 type: "setSampleInterval",
754 sampleIntervalMs: next.sampleIntervalMs,
755 });
756 }
757 const wasStreaming = previous.liveSubscriberCount > 0;
758 const isStreaming = next.liveSubscriberCount > 0;
759 if (wasStreaming !== isStreaming) {
760 yield* writeCommand(handle, {
761 version: RESOURCE_MONITOR_PROTOCOL_VERSION,
762 type: "setStreaming",
763 enabled: isStreaming,
764 });
765 }
766 }
767 });
768
769 const updateCollectionControl = (update: (current: CollectionControl) => CollectionControl) =>
770 controlMutex.withPermits(1)(
771 commitCollectionControlUpdate(
772 collectionControl,
773 appliedCollectionControl,
774 update,
775 applyCollectionControl,
776 ).pipe(Effect.ensuring(publishHealth), Effect.asVoid),
777 );
778
779 const setHostPowerState: NativeTelemetryClient["Service"]["setHostPowerState"] = (hostPower) =>
780 updateCollectionControl((current) => ({
781 ...current,
782 hostPower,
783 sampleIntervalMs: resolveNativeSampleIntervalMs(hostPower, current.liveSubscriberCount),
784 }));
785
786 const changeLiveSubscriberCount = Effect.fn(
787 "resourceTelemetry.nativeTelemetryClient.changeLiveSubscriberCount",
788 )(function* (delta: 1 | -1) {
789 yield* updateCollectionControl((current) => {
790 const liveSubscriberCount = Math.max(0, current.liveSubscriberCount + delta);
791 return {
792 ...current,
793 liveSubscriberCount,
794 sampleIntervalMs: resolveNativeSampleIntervalMs(current.hostPower, liveSubscriberCount),
795 };
796 });
797 });
798
799 const liveSnapshots = Stream.unwrap(
800 Effect.gen(function* () {
801 const subscription = yield* PubSub.subscribe(snapshots);
802 yield* Effect.acquireRelease(changeLiveSubscriberCount(1), () =>
803 changeLiveSubscriberCount(-1).pipe(Effect.ignore),
804 );
805 return Stream.fromSubscription(subscription);
806 }),
807 );
808
809 const setExternalProcesses: NativeTelemetryClient["Service"]["setExternalProcesses"] = (
810 processes,
811 ) =>
812 Effect.gen(function* () {
813 yield* Ref.set(externalProcesses, [...processes]);
814 const current = yield* Ref.get(state);
815 if (!canCommandNativeTelemetrySidecar(current.status, Option.isSome(current.handle))) return;
816 yield* writeCommand(Option.getOrThrow(current.handle), {
817 version: RESOURCE_MONITOR_PROTOCOL_VERSION,
818 type: "setExternalProcesses",
819 processes: [...processes],
820 });
821 });
822
823 const readHistory: NativeTelemetryClient["Service"]["readHistory"] = (windowMs) =>
824 Effect.gen(function* () {
825 const current = yield* Ref.get(state);
826 if (!canCommandNativeTelemetrySidecar(current.status, Option.isSome(current.handle))) {
827 return yield* new NativeTelemetryUnavailable({
828 reason: Option.getOrElse(current.lastError, () => "sidecar is not running"),
829 });
830 }
831 const requestId = yield* crypto.randomUUIDv4.pipe(
832 Effect.mapError(
833 (cause) =>
834 new NativeTelemetryCommandFailed({
835 operation: "createHistoryRequestId",
836 cause,
837 }),
838 ),
839 );
840 const deferred = yield* Deferred.make<
841 ReadonlyArray<ResourceMonitorSnapshotEvent>,
842 NativeTelemetryClientError
843 >();
844 yield* Ref.update(pendingHistories, (pending) => {
845 const next = new Map(pending);
846 next.set(requestId, { deferred, snapshots: [] });
847 return next;
848 });
849 return yield* writeCommand(Option.getOrThrow(current.handle), {
850 version: RESOURCE_MONITOR_PROTOCOL_VERSION,
851 type: "readHistory",
852 requestId,
853 windowMs: Math.max(0, Math.round(windowMs)),
854 }).pipe(
855 Effect.andThen(
856 Deferred.await(deferred).pipe(
857 Effect.timeoutOption(HISTORY_REQUEST_TIMEOUT),
858 Effect.flatMap(
859 Option.match({
860 onNone: () =>
861 Effect.fail(
862 new NativeTelemetryRequestTimedOut({
863 operation: "readHistory",
864 timeoutMs: Duration.toMillis(HISTORY_REQUEST_TIMEOUT),
865 }),
866 ),
867 onSome: Effect.succeed,
868 }),
869 ),
870 ),
871 ),
872 Effect.ensuring(
873 Ref.update(pendingHistories, (pending) => {
874 const next = new Map(pending);
875 next.delete(requestId);
876 return next;
877 }),
878 ),
879 );
880 });
881
882 const sampleNow: NativeTelemetryClient["Service"]["sampleNow"] = Effect.gen(function* () {
883 const current = yield* Ref.get(state);
884 if (!canCommandNativeTelemetrySidecar(current.status, Option.isSome(current.handle))) {
885 return yield* new NativeTelemetryUnavailable({
886 reason: Option.getOrElse(current.lastError, () => "sidecar is not running"),
887 });
888 }
889
890 const requestId = yield* crypto.randomUUIDv4.pipe(
891 Effect.mapError(
892 (cause) =>
893 new NativeTelemetryCommandFailed({
894 operation: "createRequestId",
895 cause,
896 }),
897 ),
898 );
899 const deferred = yield* Deferred.make<NativeTelemetrySnapshot, NativeTelemetryClientError>();
900 yield* Ref.update(pendingSamples, (pending) => {
901 const next = new Map(pending);
902 next.set(requestId, deferred);
903 return next;
904 });
905 return yield* writeCommand(Option.getOrThrow(current.handle), {
906 version: RESOURCE_MONITOR_PROTOCOL_VERSION,
907 type: "sampleNow",
908 requestId,
909 }).pipe(
910 Effect.andThen(
911 Deferred.await(deferred).pipe(
912 Effect.timeoutOption(SAMPLE_REQUEST_TIMEOUT),
913 Effect.flatMap(
914 Option.match({
915 onNone: () =>
916 Effect.fail(
917 new NativeTelemetryRequestTimedOut({
918 operation: "sampleNow",
919 timeoutMs: Duration.toMillis(SAMPLE_REQUEST_TIMEOUT),
920 }),
921 ),
922 onSome: Effect.succeed,
923 }),
924 ),
925 ),
926 ),
927 Effect.ensuring(
928 Ref.update(pendingSamples, (pending) => {
929 const next = new Map(pending);
930 next.delete(requestId);
931 return next;
932 }),
933 ),
934 );
935 });
936
937 const health = currentHealth;
938
939 return NativeTelemetryClient.of({
940 capabilities: Ref.get(state).pipe(
941 Effect.flatMap((current) =>
942 Option.match(current.hello, {
943 onNone: () =>
944 Effect.fail(
945 new NativeTelemetryUnavailable({
946 reason: Option.getOrElse(current.lastError, () => "handshake is incomplete"),
947 }),
948 ),
949 onSome: (hello) => Effect.succeed(hello.capabilities),
950 }),
951 ),
952 ),
953 snapshots: liveSnapshots,
954 readHistory,
955 setExternalProcesses,
956 setHostPowerState,
957 sampleNow,
958 retry: Ref.get(state).pipe(
959 Effect.flatMap((current) =>
960 !canRequestNativeTelemetryRetry(current.status, Option.isSome(current.handle))
961 ? Effect.succeed(false)
962 : Queue.offer(retryQueue, undefined).pipe(Effect.as(true)),
963 ),
964 ),
965 health,
966 subscribeHealth: subscribeBeforeSnapshotWithoutMutex(healthChanges, health),
967 });
968});
969
970export const layer = Layer.effect(NativeTelemetryClient, make());
971
972export const layerTest = (
973 overrides: Partial<NativeTelemetryClient["Service"]> = {},
974): Layer.Layer<NativeTelemetryClient> => {
975 const health =
976 overrides.health ??
977 Effect.succeed({
978 status: "unavailable" as const,
979 hello: Option.none<ResourceMonitorHelloEvent>(),
980 lastSampleAt: Option.none<DateTime.Utc>(),
981 lastError: Option.some("Resource monitor test implementation is unavailable."),
982 restartCount: 0,
983 sampleIntervalMs: UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS,
984 });
985 return Layer.succeed(
986 NativeTelemetryClient,
987 NativeTelemetryClient.of({
988 capabilities: Effect.succeed({
989 cumulativeCpuTime: true,
990 currentCpuPercent: true,
991 residentMemory: true,
992 virtualMemory: true,
993 ioBytes: true,
994 processStartTime: true,
995 processTree: true,
996 }),
997 snapshots: Stream.empty,
998 readHistory: () =>
999 Effect.fail(
1000 new NativeTelemetryUnavailable({
1001 reason: "No resource monitor history was configured for this test.",
1002 }),
1003 ),
1004 setExternalProcesses: () => Effect.void,
1005 setHostPowerState: () => Effect.void,
1006 sampleNow: Effect.fail(
1007 new NativeTelemetryUnavailable({
1008 reason: "No resource monitor sample was configured for this test.",
1009 }),
1010 ),
1011 retry: Effect.succeed(false),
1012 health,
1013 subscribeHealth:
1014 overrides.subscribeHealth ??
1015 health.pipe(
1016 Effect.map((initial) => ({
1017 latest: initial,
1018 changes: Stream.empty,
1019 })),
1020 ),
1021 ...overrides,
1022 }),
1023 );
1024};
apps/server/src/resourceTelemetry/ResourceAttribution.ts created+71
......@@ -0,0 +1,71 @@
1import type { ResourceAttributionEntry, ResourceAttributionSnapshot } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Ref from "effect/Ref";
7
8export interface ResourceAttributionRecord {
9 readonly component: string;
10 readonly operation: string;
11 readonly logicalReadBytes?: number;
12 readonly logicalWriteBytes?: number;
13 readonly count?: number;
14 readonly durationMs?: number;
15}
16
17export class ResourceAttribution extends Context.Service<
18 ResourceAttribution,
19 {
20 readonly record: (input: ResourceAttributionRecord) => Effect.Effect<void>;
21 readonly snapshot: Effect.Effect<ResourceAttributionSnapshot>;
22 }
23>()("t3/resourceTelemetry/ResourceAttribution") {}
24
25function key(input: Pick<ResourceAttributionRecord, "component" | "operation">): string {
26 return `${input.component}\u0000${input.operation}`;
27}
28
29function nonNegativeInteger(value: number | undefined, fallback: number): number {
30 if (value === undefined) return fallback;
31 if (!Number.isFinite(value)) return 0;
32 return Math.max(0, Math.round(value));
33}
34
35export const make = Effect.fn("resourceTelemetry.resourceAttribution.make")(function* () {
36 const entries = yield* Ref.make(new Map<string, ResourceAttributionEntry>());
37
38 const record: ResourceAttribution["Service"]["record"] = (input) =>
39 Ref.update(entries, (current) => {
40 const next = new Map(current);
41 const entryKey = key(input);
42 const existing = next.get(entryKey);
43 next.set(entryKey, {
44 component: input.component,
45 operation: input.operation,
46 logicalReadBytes:
47 (existing?.logicalReadBytes ?? 0) + nonNegativeInteger(input.logicalReadBytes, 0),
48 logicalWriteBytes:
49 (existing?.logicalWriteBytes ?? 0) + nonNegativeInteger(input.logicalWriteBytes, 0),
50 count: (existing?.count ?? 0) + nonNegativeInteger(input.count, 1),
51 durationMs: (existing?.durationMs ?? 0) + nonNegativeInteger(input.durationMs, 0),
52 });
53 return next;
54 });
55
56 return ResourceAttribution.of({
57 record,
58 snapshot: Effect.gen(function* () {
59 const readAt = yield* DateTime.now;
60 const current = yield* Ref.get(entries);
61 return {
62 readAt,
63 entries: [...current.values()].toSorted(
64 (left, right) => right.logicalWriteBytes - left.logicalWriteBytes,
65 ),
66 };
67 }),
68 });
69});
70
71export const layer = Layer.effect(ResourceAttribution, make());
apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts created+124
......@@ -0,0 +1,124 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import {
3 HostProcessArchitecture,
4 HostProcessEnvironment,
5 HostProcessPlatform,
6} from "@t3tools/shared/hostProcess";
7import { assert, describe, it } from "@effect/vitest";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10
11import { ServerConfig } from "../config.ts";
12import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts";
13
14describe("ResourceMonitorBinary", () => {
15 it.effect("resolves an executable override", () =>
16 Effect.gen(function* () {
17 const fileSystem = yield* FileSystem.FileSystem;
18 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
19 prefix: "t3-resource-monitor-binary-",
20 });
21 const binaryPath = `${baseDir}/t3-resource-monitor`;
22 yield* fileSystem.writeFileString(binaryPath, "binary");
23 yield* fileSystem.chmod(binaryPath, 0o755);
24
25 const service = yield* ResourceMonitorBinary.make().pipe(
26 Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)),
27 Effect.provideService(HostProcessPlatform, "linux"),
28 Effect.provideService(HostProcessArchitecture, "x64"),
29 Effect.provideService(ResourceMonitorBinary.ResourceMonitorHostLinuxLibc, "musl"),
30 Effect.provideService(HostProcessEnvironment, {
31 T3CODE_RESOURCE_MONITOR_PATH: binaryPath,
32 }),
33 );
34
35 assert.equal(yield* service.resolve, binaryPath);
36 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
37 );
38
39 it.effect("resolves an executable override on an unsupported platform", () =>
40 Effect.gen(function* () {
41 const fileSystem = yield* FileSystem.FileSystem;
42 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
43 prefix: "t3-resource-monitor-binary-",
44 });
45 const binaryPath = `${baseDir}/custom-resource-monitor`;
46 yield* fileSystem.writeFileString(binaryPath, "binary");
47 yield* fileSystem.chmod(binaryPath, 0o755);
48
49 const service = yield* ResourceMonitorBinary.make().pipe(
50 Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)),
51 Effect.provideService(HostProcessPlatform, "freebsd"),
52 Effect.provideService(HostProcessArchitecture, "ia32"),
53 Effect.provideService(HostProcessEnvironment, {
54 T3CODE_RESOURCE_MONITOR_PATH: binaryPath,
55 }),
56 );
57
58 assert.equal(yield* service.resolve, binaryPath);
59 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
60 );
61
62 it.effect("rejects a non-executable POSIX override", () =>
63 Effect.gen(function* () {
64 const fileSystem = yield* FileSystem.FileSystem;
65 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
66 prefix: "t3-resource-monitor-binary-",
67 });
68 const binaryPath = `${baseDir}/t3-resource-monitor`;
69 yield* fileSystem.writeFileString(binaryPath, "binary");
70 yield* fileSystem.chmod(binaryPath, 0o644);
71
72 const service = yield* ResourceMonitorBinary.make().pipe(
73 Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)),
74 Effect.provideService(HostProcessPlatform, "linux"),
75 Effect.provideService(HostProcessArchitecture, "x64"),
76 Effect.provideService(ResourceMonitorBinary.ResourceMonitorHostLinuxLibc, "gnu"),
77 Effect.provideService(HostProcessEnvironment, {
78 T3CODE_RESOURCE_MONITOR_PATH: binaryPath,
79 }),
80 );
81 const error = yield* Effect.flip(service.resolve);
82
83 assert.instanceOf(error, ResourceMonitorBinary.ResourceMonitorBinaryNotExecutable);
84 assert.equal(error.path, binaryPath);
85 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
86 );
87
88 it.effect("rejects unsupported platform and architecture pairs", () =>
89 Effect.gen(function* () {
90 const fileSystem = yield* FileSystem.FileSystem;
91 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
92 prefix: "t3-resource-monitor-binary-",
93 });
94 const service = yield* ResourceMonitorBinary.make().pipe(
95 Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)),
96 Effect.provideService(HostProcessPlatform, "freebsd"),
97 Effect.provideService(HostProcessArchitecture, "ia32"),
98 Effect.provideService(HostProcessEnvironment, {}),
99 );
100 const error = yield* Effect.flip(service.resolve);
101
102 assert.instanceOf(error, ResourceMonitorBinary.ResourceMonitorBinaryUnsupported);
103 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
104 );
105
106 it.effect("rejects bundled glibc binaries on musl Linux hosts", () =>
107 Effect.gen(function* () {
108 const fileSystem = yield* FileSystem.FileSystem;
109 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
110 prefix: "t3-resource-monitor-binary-",
111 });
112 const service = yield* ResourceMonitorBinary.make().pipe(
113 Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)),
114 Effect.provideService(HostProcessPlatform, "linux"),
115 Effect.provideService(HostProcessArchitecture, "x64"),
116 Effect.provideService(ResourceMonitorBinary.ResourceMonitorHostLinuxLibc, "musl"),
117 Effect.provideService(HostProcessEnvironment, {}),
118 );
119 const error = yield* Effect.flip(service.resolve);
120
121 assert.instanceOf(error, ResourceMonitorBinary.ResourceMonitorBinaryUnsupported);
122 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
123 );
124});
apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts created+226
......@@ -0,0 +1,226 @@
1import {
2 HostProcessArchitecture,
3 HostProcessEnvironment,
4 HostProcessPlatform,
5} from "@t3tools/shared/hostProcess";
6import * as Context from "effect/Context";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";
11import * as Path from "effect/Path";
12import * as Schema from "effect/Schema";
13
14import { ServerConfig } from "../config.ts";
15
16export class ResourceMonitorBinaryUnsupported extends Schema.TaggedErrorClass<ResourceMonitorBinaryUnsupported>()(
17 "ResourceMonitorBinaryUnsupported",
18 {
19 platform: Schema.String,
20 architecture: Schema.String,
21 },
22) {
23 override get message(): string {
24 return `Resource monitoring is unsupported on ${this.platform}/${this.architecture}.`;
25 }
26}
27
28export class ResourceMonitorBinaryNotFound extends Schema.TaggedErrorClass<ResourceMonitorBinaryNotFound>()(
29 "ResourceMonitorBinaryNotFound",
30 {
31 platform: Schema.String,
32 architecture: Schema.String,
33 candidates: Schema.Array(Schema.String),
34 },
35) {
36 override get message(): string {
37 return `Resource monitor binary was not found for ${this.platform}/${this.architecture}.`;
38 }
39}
40
41export class ResourceMonitorBinaryNotExecutable extends Schema.TaggedErrorClass<ResourceMonitorBinaryNotExecutable>()(
42 "ResourceMonitorBinaryNotExecutable",
43 {
44 path: Schema.String,
45 mode: Schema.Number,
46 },
47) {
48 override get message(): string {
49 return `Resource monitor binary at '${this.path}' is not executable.`;
50 }
51}
52
53export type ResourceMonitorBinaryError =
54 | ResourceMonitorBinaryUnsupported
55 | ResourceMonitorBinaryNotFound
56 | ResourceMonitorBinaryNotExecutable;
57
58export class ResourceMonitorBinary extends Context.Service<
59 ResourceMonitorBinary,
60 {
61 readonly resolve: Effect.Effect<string, ResourceMonitorBinaryError>;
62 }
63>()("t3/resourceTelemetry/ResourceMonitorBinary") {}
64
65function binaryName(platform: NodeJS.Platform): string {
66 return platform === "win32" ? "t3-resource-monitor.exe" : "t3-resource-monitor";
67}
68
69export type ResourceMonitorLinuxLibc = "gnu" | "musl";
70
71function detectResourceMonitorLinuxLibc(): ResourceMonitorLinuxLibc {
72 try {
73 const report = process.report?.getReport() as
74 | {
75 readonly header?: {
76 readonly glibcVersionRuntime?: unknown;
77 };
78 }
79 | undefined;
80 return typeof report?.header?.glibcVersionRuntime === "string" ? "gnu" : "musl";
81 } catch {
82 return "musl";
83 }
84}
85
86export const ResourceMonitorHostLinuxLibc = Context.Reference<ResourceMonitorLinuxLibc>(
87 "t3/resourceTelemetry/ResourceMonitorHostLinuxLibc",
88 {
89 defaultValue: detectResourceMonitorLinuxLibc,
90 },
91);
92
93export function resourceMonitorPlatformKey(
94 platform: NodeJS.Platform,
95 architecture: NodeJS.Architecture,
96): string | undefined {
97 if (
98 (platform !== "darwin" && platform !== "linux" && platform !== "win32") ||
99 (architecture !== "arm64" && architecture !== "x64")
100 ) {
101 return undefined;
102 }
103 return `${platform}-${architecture}`;
104}
105
106export function resourceMonitorRustTarget(
107 platform: NodeJS.Platform,
108 architecture: NodeJS.Architecture,
109 linuxLibc: ResourceMonitorLinuxLibc,
110): string | undefined {
111 if (platform === "darwin") {
112 return architecture === "arm64"
113 ? "aarch64-apple-darwin"
114 : architecture === "x64"
115 ? "x86_64-apple-darwin"
116 : undefined;
117 }
118 if (platform === "linux") {
119 if (linuxLibc !== "gnu") {
120 return undefined;
121 }
122 return architecture === "arm64"
123 ? "aarch64-unknown-linux-gnu"
124 : architecture === "x64"
125 ? "x86_64-unknown-linux-gnu"
126 : undefined;
127 }
128 if (platform === "win32") {
129 return architecture === "arm64"
130 ? "aarch64-pc-windows-msvc"
131 : architecture === "x64"
132 ? "x86_64-pc-windows-msvc"
133 : undefined;
134 }
135 return undefined;
136}
137
138export const make = Effect.fn("resourceTelemetry.resourceMonitorBinary.make")(function* () {
139 const config = yield* ServerConfig;
140 const fileSystem = yield* FileSystem.FileSystem;
141 const path = yield* Path.Path;
142 const platform = yield* HostProcessPlatform;
143 const architecture = yield* HostProcessArchitecture;
144 const environment = yield* HostProcessEnvironment;
145 const linuxLibc = yield* ResourceMonitorHostLinuxLibc;
146 const executableName = binaryName(platform);
147 const platformKey = resourceMonitorPlatformKey(platform, architecture);
148 const rustTarget = resourceMonitorRustTarget(platform, architecture, linuxLibc);
149 const overrideCandidates = [
150 environment.T3CODE_RESOURCE_MONITOR_PATH,
151 config.resourceMonitorPath,
152 ].filter((candidate): candidate is string => Boolean(candidate));
153 const bundledCandidates =
154 platformKey === undefined || rustTarget === undefined
155 ? []
156 : [
157 path.resolve(import.meta.dirname, "resource-monitor", platformKey, executableName),
158 path.resolve(import.meta.dirname, "resource-monitor", executableName),
159 path.resolve(import.meta.dirname, "../resource-monitor", executableName),
160 path.resolve(
161 import.meta.dirname,
162 "../../../../native/resource-monitor/target",
163 rustTarget,
164 "release",
165 executableName,
166 ),
167 path.resolve(
168 import.meta.dirname,
169 "../../../native/resource-monitor/target",
170 rustTarget,
171 "release",
172 executableName,
173 ),
174 path.resolve(
175 import.meta.dirname,
176 "../../../../native/resource-monitor/target/release",
177 executableName,
178 ),
179 path.resolve(
180 import.meta.dirname,
181 "../../../../native/resource-monitor/target/debug",
182 executableName,
183 ),
184 ];
185 if (overrideCandidates.length === 0 && bundledCandidates.length === 0) {
186 return ResourceMonitorBinary.of({
187 resolve: Effect.fail(
188 new ResourceMonitorBinaryUnsupported({
189 platform,
190 architecture,
191 }),
192 ),
193 });
194 }
195
196 const candidates = [...overrideCandidates, ...bundledCandidates];
197
198 const resolve: ResourceMonitorBinary["Service"]["resolve"] = Effect.gen(function* () {
199 for (const candidate of candidates) {
200 const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false));
201 if (!exists) continue;
202
203 if (platform !== "win32") {
204 const stat = yield* fileSystem.stat(candidate).pipe(Effect.option);
205 if (Option.isSome(stat) && (stat.value.mode & 0o111) === 0) {
206 return yield* new ResourceMonitorBinaryNotExecutable({
207 path: candidate,
208 mode: stat.value.mode,
209 });
210 }
211 }
212
213 return candidate;
214 }
215
216 return yield* new ResourceMonitorBinaryNotFound({
217 platform,
218 architecture,
219 candidates,
220 });
221 });
222
223 return ResourceMonitorBinary.of({ resolve });
224});
225
226export const layer = Layer.effect(ResourceMonitorBinary, make());
apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts created+647
......@@ -0,0 +1,647 @@
1import type {
2 DesktopHostTelemetrySnapshot,
3 ResourceMonitorProcessSample,
4 ResourceMonitorSnapshotEvent,
5} from "@t3tools/contracts";
6import { describe, expect, it } from "@effect/vitest";
7import * as DateTime from "effect/DateTime";
8import * as Deferred from "effect/Deferred";
9import * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";
11import * as Fiber from "effect/Fiber";
12import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";
14import * as PubSub from "effect/PubSub";
15import * as Ref from "effect/Ref";
16import * as Stream from "effect/Stream";
17import * as TestClock from "effect/testing/TestClock";
18
19import * as DesktopTelemetryReceiver from "./DesktopTelemetryReceiver.ts";
20import * as NativeTelemetryClient from "./NativeTelemetryClient.ts";
21import * as ResourceAttribution from "./ResourceAttribution.ts";
22import * as ResourceTelemetry from "./ResourceTelemetry.ts";
23
24function processSample(
25 input: Partial<ResourceMonitorProcessSample> &
26 Pick<ResourceMonitorProcessSample, "pid" | "ppid" | "startTimeMs">,
27): ResourceMonitorProcessSample {
28 return {
29 runTimeMs: 1_000,
30 name: `process-${input.pid}`,
31 command: `process-${input.pid}`,
32 status: "Running",
33 cpuPercent: 0,
34 cpuTimeMs: 0,
35 residentBytes: 1_024,
36 virtualBytes: 2_048,
37 ioReadBytes: 0,
38 ioWriteBytes: 0,
39 ioSemantics: "storage",
40 ...input,
41 };
42}
43
44function nativeSnapshot(input: {
45 readonly sequence: number;
46 readonly sampledAtUnixMs: number;
47 readonly childCpuTimeMs: number;
48 readonly childWriteBytes: number;
49 readonly externalProcesses?: ResourceMonitorSnapshotEvent["externalProcesses"];
50}): ResourceMonitorSnapshotEvent {
51 const processes = [
52 processSample({
53 pid: process.pid,
54 ppid: 1,
55 startTimeMs: 100,
56 cpuTimeMs: input.sequence * 10,
57 }),
58 processSample({
59 pid: 4_242,
60 ppid: process.pid,
61 startTimeMs: 200,
62 name: "codex",
63 command: "codex app-server",
64 cpuTimeMs: input.childCpuTimeMs,
65 ioWriteBytes: input.childWriteBytes,
66 }),
67 processSample({
68 pid: 5_000,
69 ppid: 1,
70 startTimeMs: 300,
71 name: "electron",
72 command: "electron",
73 cpuTimeMs: input.sequence * 20,
74 }),
75 processSample({
76 pid: 9_000,
77 ppid: process.pid,
78 startTimeMs: 400,
79 name: "t3-resource-monitor",
80 command: "t3-resource-monitor",
81 cpuTimeMs: input.sequence * 5,
82 }),
83 ];
84 return {
85 version: 2,
86 type: "snapshot",
87 sequence: input.sequence,
88 sampledAtUnixMs: input.sampledAtUnixMs,
89 collectionDurationMicros: 300,
90 scannedProcessCount: 80,
91 retainedProcessCount: processes.length,
92 inaccessibleProcessCount: 1,
93 ...(input.externalProcesses === undefined
94 ? {}
95 : { externalProcesses: input.externalProcesses }),
96 processes,
97 };
98}
99
100function nativeGeneration(
101 snapshot: ResourceMonitorSnapshotEvent,
102 generation: number,
103): NativeTelemetryClient.NativeTelemetrySnapshot {
104 return { generation, snapshot };
105}
106
107function desktopSnapshot(sampledAtUnixMs: number): DesktopHostTelemetrySnapshot {
108 const sampledAt = DateTime.makeUnsafe(sampledAtUnixMs);
109 return {
110 version: 1,
111 type: "desktopTelemetry",
112 sequence: 1,
113 sampledAtUnixMs,
114 electronPid: 5_000,
115 power: {
116 source: "electron-main",
117 idle: "false",
118 idleSeconds: 2,
119 locked: "false",
120 suspended: false,
121 onBattery: "true",
122 lowPowerMode: "unknown",
123 thermalState: "fair",
124 stale: false,
125 updatedAt: sampledAt,
126 },
127 speedLimitPercent: Option.some(90),
128 electronProcesses: [
129 {
130 pid: 5_000,
131 creationTimeMs: 300,
132 type: "Browser",
133 name: "electron",
134 cpuPercent: 2,
135 cumulativeCpuSeconds: 0.02,
136 idleWakeupsPerSecond: 3,
137 workingSetBytes: 4_096,
138 peakWorkingSetBytes: 8_192,
139 },
140 ],
141 };
142}
143
144describe("ResourceTelemetry", () => {
145 it.effect("enables live native and Electron collection only while changes are retained", () =>
146 Effect.gen(function* () {
147 const sampledAtUnixMs = DateTime.toEpochMillis(yield* DateTime.now);
148 const sample = nativeSnapshot({
149 sequence: 1,
150 sampledAtUnixMs,
151 childCpuTimeMs: 100,
152 childWriteBytes: 1_000,
153 });
154 const demandChanges = yield* Ref.make<ReadonlyArray<boolean>>([]);
155 const nativeLayer = NativeTelemetryClient.layerTest({
156 sampleNow: Effect.succeed(nativeGeneration(sample, 0)),
157 health: Effect.succeed({
158 status: "healthy",
159 hello: Option.none(),
160 lastSampleAt: Option.none(),
161 lastError: Option.none(),
162 restartCount: 0,
163 sampleIntervalMs: 1_000,
164 }),
165 });
166 const desktopLayer = DesktopTelemetryReceiver.layerTest({
167 latest: Effect.succeedSome(desktopSnapshot(sampledAtUnixMs)),
168 setDiagnosticsDemand: (enabled) =>
169 Ref.update(demandChanges, (changes) => [...changes, enabled]),
170 });
171 const telemetryLayer = ResourceTelemetry.layer.pipe(
172 Layer.provide(Layer.mergeAll(nativeLayer, desktopLayer, ResourceAttribution.layer)),
173 );
174
175 const live = yield* Stream.runHead(
176 Effect.gen(function* () {
177 const telemetry = yield* ResourceTelemetry.ResourceTelemetry;
178 return telemetry.changes;
179 }).pipe(Stream.unwrap),
180 ).pipe(Effect.provide(telemetryLayer));
181
182 expect(Option.isSome(live)).toBe(true);
183 expect(yield* Ref.get(demandChanges)).toEqual([true, false]);
184 }),
185 );
186
187 it.effect("releases live demand when acquisition is interrupted by an idle collector", () =>
188 Effect.gen(function* () {
189 const sampledAtUnixMs = DateTime.toEpochMillis(yield* DateTime.now);
190 const demandChanges = yield* Ref.make<ReadonlyArray<boolean>>([]);
191 const nativeLayer = NativeTelemetryClient.layerTest({
192 sampleNow: Effect.never,
193 health: Effect.succeed({
194 status: "healthy",
195 hello: Option.none(),
196 lastSampleAt: Option.none(),
197 lastError: Option.none(),
198 restartCount: 0,
199 sampleIntervalMs: 1_000,
200 }),
201 });
202 const desktopLayer = DesktopTelemetryReceiver.layerTest({
203 latest: Effect.succeedSome(desktopSnapshot(sampledAtUnixMs)),
204 setDiagnosticsDemand: (enabled) =>
205 Ref.update(demandChanges, (changes) => [...changes, enabled]),
206 });
207 const telemetryLayer = ResourceTelemetry.layer.pipe(
208 Layer.provide(Layer.mergeAll(nativeLayer, desktopLayer, ResourceAttribution.layer)),
209 );
210
211 const resultFiber = yield* Stream.runHead(
212 Effect.gen(function* () {
213 const telemetry = yield* ResourceTelemetry.ResourceTelemetry;
214 return telemetry.changes;
215 }).pipe(Stream.unwrap),
216 ).pipe(Effect.timeoutOption("10 millis"), Effect.provide(telemetryLayer), Effect.forkChild);
217 yield* Effect.yieldNow;
218 yield* TestClock.adjust("10 millis");
219 const result = yield* Fiber.join(resultFiber);
220
221 expect(Option.isNone(result)).toBe(true);
222 expect(yield* Ref.get(demandChanges)).toEqual([true, false]);
223 }),
224 );
225
226 it.effect(
227 "attributes an initial Electron root from the identity recorded by the native snapshot",
228 () =>
229 Effect.gen(function* () {
230 const sampledAtUnixMs = DateTime.toEpochMillis(yield* DateTime.now);
231 const sample = nativeSnapshot({
232 sequence: 1,
233 sampledAtUnixMs,
234 childCpuTimeMs: 100,
235 childWriteBytes: 1_000,
236 externalProcesses: [{ pid: 5_000, startTimeMs: 300 }],
237 });
238 const desktop = {
239 ...desktopSnapshot(sampledAtUnixMs),
240 electronProcesses: [],
241 };
242 const nativeLayer = NativeTelemetryClient.layerTest({
243 sampleNow: Effect.succeed(nativeGeneration(sample, 0)),
244 health: Effect.succeed({
245 status: "healthy",
246 hello: Option.none(),
247 lastSampleAt: Option.none(),
248 lastError: Option.none(),
249 restartCount: 0,
250 sampleIntervalMs: 1_000,
251 }),
252 });
253 const desktopLayer = DesktopTelemetryReceiver.layerTest({
254 latest: Effect.succeedSome(desktop),
255 });
256 const telemetryLayer = ResourceTelemetry.layer.pipe(
257 Layer.provide(Layer.mergeAll(nativeLayer, desktopLayer, ResourceAttribution.layer)),
258 );
259
260 const snapshot = yield* Effect.gen(function* () {
261 const telemetry = yield* ResourceTelemetry.ResourceTelemetry;
262 return yield* telemetry.refresh;
263 }).pipe(Effect.provide(telemetryLayer));
264
265 expect(snapshot.processes.find((entry) => entry.identity.pid === 5_000)?.category).toBe(
266 "electron-main",
267 );
268 expect(snapshot.groups.electron.processStarts).toBe(1);
269 expect(snapshot.groups.backend.processStarts).toBe(3);
270 }),
271 );
272
273 it.effect("rejects buffered snapshots from an earlier sidecar generation", () =>
274 Effect.scoped(
275 Effect.gen(function* () {
276 const startedAt = DateTime.toEpochMillis(yield* DateTime.now);
277 const stale = nativeSnapshot({
278 sequence: 100,
279 sampledAtUnixMs: startedAt + 1_000,
280 childCpuTimeMs: 100,
281 childWriteBytes: 1_000,
282 });
283 const current = nativeSnapshot({
284 sequence: 1,
285 sampledAtUnixMs: startedAt + 2_000,
286 childCpuTimeMs: 200,
287 childWriteBytes: 2_000,
288 });
289 const nativeSnapshots =
290 yield* PubSub.unbounded<NativeTelemetryClient.NativeTelemetrySnapshot>();
291 const nativeLayer = NativeTelemetryClient.layerTest({
292 snapshots: Stream.fromPubSub(nativeSnapshots),
293 sampleNow: Effect.never,
294 health: Effect.succeed({
295 status: "healthy",
296 hello: Option.none(),
297 lastSampleAt: Option.none(),
298 lastError: Option.none(),
299 restartCount: 1,
300 sampleIntervalMs: 1_000,
301 }),
302 });
303 const telemetryLayer = ResourceTelemetry.layer.pipe(
304 Layer.provide(
305 Layer.mergeAll(
306 nativeLayer,
307 DesktopTelemetryReceiver.layerTest(),
308 ResourceAttribution.layer,
309 ),
310 ),
311 );
312
313 yield* Effect.gen(function* () {
314 const telemetry = yield* ResourceTelemetry.ResourceTelemetry;
315 const subscription = yield* telemetry.subscribe;
316 const nextSnapshot = yield* Stream.runHead(subscription.changes).pipe(Effect.forkChild);
317
318 yield* PubSub.publish(nativeSnapshots, nativeGeneration(stale, 0));
319 yield* Effect.yieldNow;
320 expect((yield* telemetry.latest).health.scannedProcessCount).toBe(0);
321
322 yield* PubSub.publish(nativeSnapshots, nativeGeneration(current, 1));
323 const received = yield* Fiber.join(nextSnapshot);
324
325 expect(Option.isSome(received)).toBe(true);
326 expect(DateTime.toEpochMillis(Option.getOrThrow(received).readAt)).toBe(
327 current.sampledAtUnixMs,
328 );
329 }).pipe(Effect.provide(telemetryLayer));
330 }),
331 ),
332 );
333
334 it.effect("retains desktop health changes while aggregate telemetry initializes", () =>
335 Effect.scoped(
336 Effect.gen(function* () {
337 const health = yield* Ref.make<DesktopTelemetryReceiver.DesktopTelemetryReceiverHealth>({
338 status: "starting",
339 lastSampleAt: Option.none(),
340 lastError: Option.none(),
341 });
342 const healthChanges =
343 yield* PubSub.sliding<DesktopTelemetryReceiver.DesktopTelemetryReceiverHealth>(4);
344 const healthSubscribed = yield* Deferred.make<void>();
345 const finishDesktopSnapshot = yield* Deferred.make<void>();
346 const desktopLayer = DesktopTelemetryReceiver.layerTest({
347 health: Ref.get(health),
348 subscribeHealth: Effect.gen(function* () {
349 const subscription = yield* PubSub.subscribe(healthChanges);
350 const latest = yield* Ref.get(health);
351 yield* Deferred.succeed(healthSubscribed, undefined);
352 return {
353 latest,
354 changes: Stream.fromSubscription(subscription),
355 };
356 }),
357 subscribe: Deferred.await(finishDesktopSnapshot).pipe(
358 Effect.as({
359 latest: Option.none(),
360 changes: Stream.empty,
361 }),
362 ),
363 });
364 const telemetryLayer = ResourceTelemetry.layer.pipe(
365 Layer.provide(
366 Layer.mergeAll(
367 NativeTelemetryClient.layerTest(),
368 desktopLayer,
369 ResourceAttribution.layer,
370 ),
371 ),
372 );
373 const resultFiber = yield* Effect.gen(function* () {
374 const telemetry = yield* ResourceTelemetry.ResourceTelemetry;
375 while (true) {
376 const snapshot = yield* telemetry.latest;
377 if (snapshot.health.desktop.status === "degraded") return snapshot;
378 yield* Effect.yieldNow;
379 }
380 }).pipe(Effect.provide(telemetryLayer), Effect.timeout("1 second"), Effect.forkChild);
381
382 yield* Deferred.await(healthSubscribed);
383 const degraded: DesktopTelemetryReceiver.DesktopTelemetryReceiverHealth = {
384 status: "degraded",
385 lastSampleAt: Option.none(),
386 lastError: Option.some("desktop telemetry failed"),
387 };
388 yield* Ref.set(health, degraded);
389 yield* PubSub.publish(healthChanges, degraded);
390 yield* Deferred.succeed(finishDesktopSnapshot, undefined);
391
392 const snapshot = yield* Fiber.join(resultFiber);
393 expect(snapshot.health.desktop.status).toBe("degraded");
394 expect(Option.getOrNull(snapshot.health.desktop.lastError)).toBe(
395 "desktop telemetry failed",
396 );
397 }),
398 ),
399 );
400
401 it.effect("atomically subscribes while a desktop update is being rebuilt", () =>
402 Effect.scoped(
403 Effect.gen(function* () {
404 const startedAt = DateTime.toEpochMillis(yield* DateTime.now);
405 const nextDesktopSnapshot = desktopSnapshot(startedAt + 1_000);
406 const desktopChanges = yield* PubSub.unbounded<DesktopHostTelemetrySnapshot>();
407 const rebuildStarted = yield* Deferred.make<void>();
408 const finishRebuild = yield* Deferred.make<void>();
409 const attributionReads = yield* Ref.make(0);
410 const attributionLayer = Layer.succeed(
411 ResourceAttribution.ResourceAttribution,
412 ResourceAttribution.ResourceAttribution.of({
413 record: () => Effect.void,
414 snapshot: Ref.updateAndGet(attributionReads, (count) => count + 1).pipe(
415 Effect.flatMap((count) =>
416 count === 1
417 ? Effect.void
418 : Deferred.succeed(rebuildStarted, undefined).pipe(
419 Effect.andThen(Deferred.await(finishRebuild)),
420 ),
421 ),
422 Effect.andThen(
423 Effect.succeed({
424 readAt: DateTime.makeUnsafe(startedAt),
425 entries: [],
426 }),
427 ),
428 ),
429 }),
430 );
431 const nativeLayer = NativeTelemetryClient.layerTest({
432 sampleNow: Effect.never,
433 health: Effect.succeed({
434 status: "healthy",
435 hello: Option.none(),
436 lastSampleAt: Option.none(),
437 lastError: Option.none(),
438 restartCount: 0,
439 sampleIntervalMs: 1_000,
440 }),
441 });
442 const desktopLayer = DesktopTelemetryReceiver.layerTest({
443 changes: Stream.fromPubSub(desktopChanges),
444 });
445 const telemetryLayer = ResourceTelemetry.layer.pipe(
446 Layer.provide(Layer.mergeAll(nativeLayer, desktopLayer, attributionLayer)),
447 );
448
449 yield* Effect.gen(function* () {
450 const telemetry = yield* ResourceTelemetry.ResourceTelemetry;
451 yield* Effect.yieldNow;
452 yield* PubSub.publish(desktopChanges, nextDesktopSnapshot);
453 yield* Deferred.await(rebuildStarted);
454
455 const subscriptionFiber = yield* telemetry.subscribe.pipe(Effect.forkChild);
456 yield* Effect.yieldNow;
457 yield* Deferred.succeed(finishRebuild, undefined);
458 const subscription = yield* Fiber.join(subscriptionFiber);
459
460 expect(DateTime.toEpochMillis(subscription.latest.readAt)).toBe(
461 nextDesktopSnapshot.sampledAtUnixMs,
462 );
463 expect(subscription.latest.power).toEqual(nextDesktopSnapshot.power);
464 }).pipe(Effect.provide(telemetryLayer));
465 }),
466 ),
467 );
468
469 it.effect("combines native, Electron, attribution, retry, and history data", () =>
470 Effect.gen(function* () {
471 const startedAt = DateTime.toEpochMillis(yield* DateTime.now);
472 const samples = [
473 nativeSnapshot({
474 sequence: 1,
475 sampledAtUnixMs: startedAt,
476 childCpuTimeMs: 100,
477 childWriteBytes: 1_000,
478 }),
479 nativeSnapshot({
480 sequence: 2,
481 sampledAtUnixMs: startedAt + 1_000,
482 childCpuTimeMs: 350,
483 childWriteBytes: 5_000,
484 }),
485 nativeSnapshot({
486 sequence: 1,
487 sampledAtUnixMs: startedAt + 2_000,
488 childCpuTimeMs: 500,
489 childWriteBytes: 7_000,
490 }),
491 ] as const;
492 const sampleIndex = yield* Ref.make(0);
493 const externalProcesses = yield* Ref.make<
494 ReadonlyArray<{ readonly pid: number; readonly startTimeMs?: number }>
495 >([]);
496 const retryCount = yield* Ref.make(0);
497 const nativeHealth = yield* Ref.make<NativeTelemetryClient.NativeTelemetryClientHealth>({
498 status: "healthy",
499 hello: Option.some({
500 version: 2,
501 type: "hello",
502 sidecarVersion: "0.1.0",
503 sidecarPid: 9_000,
504 platform: "test",
505 arch: "test",
506 capabilities: {
507 cumulativeCpuTime: true,
508 currentCpuPercent: true,
509 residentMemory: true,
510 virtualMemory: true,
511 ioBytes: true,
512 processStartTime: true,
513 processTree: true,
514 },
515 }),
516 lastSampleAt: Option.some(DateTime.makeUnsafe(startedAt)),
517 lastError: Option.none(),
518 restartCount: 2,
519 sampleIntervalMs: 1_000,
520 });
521 const nativeHealthChanges =
522 yield* PubSub.sliding<NativeTelemetryClient.NativeTelemetryClientHealth>(4);
523 const nativeLayer = NativeTelemetryClient.layerTest({
524 setExternalProcesses: (processes) => Ref.set(externalProcesses, processes),
525 readHistory: () => Effect.succeed(samples.slice(0, 2)),
526 sampleNow: Ref.modify(sampleIndex, (index) => {
527 const sampleIndex = Math.min(index, samples.length - 1);
528 return [nativeGeneration(samples[sampleIndex]!, sampleIndex === 2 ? 3 : 2), index + 1];
529 }),
530 retry: Ref.updateAndGet(retryCount, (count) => count + 1).pipe(Effect.as(true)),
531 health: Ref.get(nativeHealth),
532 subscribeHealth: Effect.gen(function* () {
533 const subscription = yield* PubSub.subscribe(nativeHealthChanges);
534 const latest = yield* Ref.get(nativeHealth);
535 return {
536 latest,
537 changes: Stream.fromSubscription(subscription),
538 };
539 }),
540 });
541 const desktopLayer = DesktopTelemetryReceiver.layerTest({
542 latest: Effect.succeedSome(desktopSnapshot(startedAt)),
543 health: Effect.succeed({
544 status: "healthy",
545 lastSampleAt: Option.some(DateTime.makeUnsafe(startedAt)),
546 lastError: Option.none(),
547 }),
548 });
549 const attributionLayer = ResourceAttribution.layer;
550 const dependencies = Layer.mergeAll(nativeLayer, desktopLayer, attributionLayer);
551 const telemetryLayer = ResourceTelemetry.layer.pipe(Layer.provide(dependencies));
552 const layer = Layer.mergeAll(dependencies, telemetryLayer);
553
554 yield* Effect.gen(function* () {
555 const telemetry = yield* ResourceTelemetry.ResourceTelemetry;
556 const attribution = yield* ResourceAttribution.ResourceAttribution;
557
558 expect(yield* Ref.get(externalProcesses)).toEqual([{ pid: 5_000, startTimeMs: 300 }]);
559
560 yield* attribution.record({
561 component: "provider-event-log",
562 operation: "append",
563 logicalWriteBytes: 512,
564 count: 2,
565 durationMs: 4,
566 });
567 const first = yield* telemetry.refresh;
568 expect(first.groups.backend.processCount).toBe(2);
569 expect(first.groups.electron.processCount).toBe(1);
570 expect(first.groups.monitor.processCount).toBe(1);
571 expect(first.power.onBattery).toBe("true");
572 expect(Option.getOrNull(first.speedLimitPercent)).toBe(90);
573 expect(first.attribution.entries).toEqual([
574 {
575 component: "provider-event-log",
576 operation: "append",
577 logicalReadBytes: 0,
578 logicalWriteBytes: 512,
579 count: 2,
580 durationMs: 4,
581 },
582 ]);
583
584 yield* TestClock.adjust(Duration.seconds(1));
585 const second = yield* telemetry.refresh;
586 const codex = second.processes.find((entry) => entry.identity.pid === 4_242);
587 expect(codex?.cpuPercent).toBe(25);
588 expect(codex?.ioWriteBytesPerSecond).toBe(4_000);
589 expect(second.groups.backend.ioWriteBytes).toBe(4_000);
590 expect(second.health.collectionDurationMicros).toBe(300);
591 expect(second.health.scannedProcessCount).toBe(80);
592 expect(second.health.inaccessibleProcessCount).toBe(1);
593
594 const history = yield* telemetry.readHistory({
595 windowMs: 60_000,
596 bucketMs: 10_000,
597 });
598 expect(history.retainedSampleCount).toBeGreaterThan(0);
599 expect(
600 history.topProcesses.find((entry) => entry.identity.pid === 4_242)?.sampleCount,
601 ).toBe(2);
602 expect(history.topProcesses.find((entry) => entry.identity.pid === 4_242)?.cpuTimeMs).toBe(
603 250,
604 );
605 expect(
606 history.topProcesses.find((entry) => entry.identity.pid === 4_242)?.ioWriteBytes,
607 ).toBe(4_000);
608 expect(history.buckets.reduce((total, bucket) => total + bucket.ioWriteBytes, 0)).toBe(
609 4_000,
610 );
611
612 const retry = yield* telemetry.retry;
613 expect(retry.accepted).toBe(true);
614 expect(yield* Ref.get(retryCount)).toBe(1);
615
616 yield* Ref.update(nativeHealth, (current) => ({
617 ...current,
618 hello: Option.map(current.hello, (hello) => ({
619 ...hello,
620 sidecarPid: 9_001,
621 })),
622 restartCount: 3,
623 }));
624 yield* TestClock.adjust(Duration.seconds(1));
625 const restarted = yield* telemetry.refresh;
626 expect(DateTime.toEpochMillis(restarted.readAt)).toBe(startedAt + 2_000);
627 expect(Option.getOrNull(restarted.health.sidecarPid)).toBe(9_001);
628
629 yield* Ref.update(nativeHealth, (current) => ({
630 ...current,
631 status: "degraded" as const,
632 lastError: Option.some("collector exited"),
633 }));
634 yield* PubSub.publish(nativeHealthChanges, yield* Ref.get(nativeHealth));
635 yield* Effect.yieldNow;
636 const healthUpdate = yield* telemetry.latest;
637 expect(healthUpdate.health.native.status).toBe("degraded");
638 expect(Option.getOrNull(healthUpdate.health.native.lastError)).toBe("collector exited");
639 const degradedHistory = yield* telemetry.readHistory({
640 windowMs: 60_000,
641 bucketMs: 10_000,
642 });
643 expect(degradedHistory.health.native.status).toBe("degraded");
644 }).pipe(Effect.provide(layer));
645 }),
646 );
647});
apps/server/src/resourceTelemetry/ResourceTelemetry.ts created+504
......@@ -0,0 +1,504 @@
1import type {
2 DesktopHostTelemetrySnapshot,
3 HostPowerSnapshot,
4 ResourceMonitorSnapshotEvent,
5 ResourceTelemetryHealth,
6 ResourceTelemetryHistoryInput,
7 ResourceTelemetryProcessIdentity,
8 ResourceTelemetryRetryResult,
9 ResourceTelemetrySnapshot,
10} from "@t3tools/contracts";
11import * as Context from "effect/Context";
12import * as DateTime from "effect/DateTime";
13import * as Effect from "effect/Effect";
14import * as Exit from "effect/Exit";
15import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";
17import * as PubSub from "effect/PubSub";
18import * as Ref from "effect/Ref";
19import * as Result from "effect/Result";
20import * as Schema from "effect/Schema";
21import * as Scope from "effect/Scope";
22import * as Semaphore from "effect/Semaphore";
23import * as Stream from "effect/Stream";
24
25import * as DesktopTelemetryReceiver from "./DesktopTelemetryReceiver.ts";
26import {
27 emptyTelemetryCounters,
28 mergeProcesses,
29 type ProcessState,
30 type TelemetryCounters,
31} from "./Model.ts";
32import * as NativeTelemetryClient from "./NativeTelemetryClient.ts";
33import * as ResourceAttribution from "./ResourceAttribution.ts";
34import {
35 buildResourceTelemetryHistory,
36 normalizeResourceTelemetryHistoryInput,
37 type ResourceTelemetryHistoryWithLegacyBuckets,
38} from "./ResourceTelemetryHistory.ts";
39import { subscribeBeforeSnapshot } from "../utils/subscribeBeforeSnapshot.ts";
40
41export class ResourceTelemetryRefreshFailed extends Schema.TaggedErrorClass<ResourceTelemetryRefreshFailed>()(
42 "ResourceTelemetryRefreshFailed",
43 {
44 operation: Schema.String,
45 cause: Schema.Defect(),
46 },
47) {
48 override get message(): string {
49 return `Resource telemetry operation '${this.operation}' failed.`;
50 }
51}
52
53export class ResourceTelemetry extends Context.Service<
54 ResourceTelemetry,
55 {
56 readonly latest: Effect.Effect<ResourceTelemetrySnapshot>;
57 readonly changes: Stream.Stream<ResourceTelemetrySnapshot>;
58 readonly subscribe: Effect.Effect<
59 {
60 readonly latest: ResourceTelemetrySnapshot;
61 readonly changes: Stream.Stream<ResourceTelemetrySnapshot>;
62 },
63 never,
64 Scope.Scope
65 >;
66 readonly readHistory: (
67 input: ResourceTelemetryHistoryInput,
68 ) => Effect.Effect<ResourceTelemetryHistoryWithLegacyBuckets>;
69 readonly refresh: Effect.Effect<ResourceTelemetrySnapshot, ResourceTelemetryRefreshFailed>;
70 readonly validateProcessIdentity: (
71 identity: ResourceTelemetryProcessIdentity,
72 ) => Effect.Effect<boolean, ResourceTelemetryRefreshFailed>;
73 readonly retry: Effect.Effect<ResourceTelemetryRetryResult>;
74 }
75>()("t3/resourceTelemetry/ResourceTelemetry") {}
76
77interface TelemetryState {
78 readonly nativeSnapshot: Option.Option<ResourceMonitorSnapshotEvent>;
79 readonly desktopSnapshot: Option.Option<DesktopHostTelemetrySnapshot>;
80 readonly previous: ReadonlyMap<string, ProcessState>;
81 readonly counters: TelemetryCounters;
82 readonly latest: ResourceTelemetrySnapshot;
83 readonly lastNativeSequence: number;
84 readonly lastNativeGeneration: number;
85}
86
87interface LiveTelemetryState {
88 readonly retainCount: number;
89 readonly scope: Option.Option<Scope.Closeable>;
90}
91
92function unknownPower(updatedAt: DateTime.Utc): HostPowerSnapshot {
93 return {
94 source: "unknown",
95 idle: "unknown",
96 idleSeconds: null,
97 locked: "unknown",
98 suspended: false,
99 onBattery: "unknown",
100 lowPowerMode: "unknown",
101 thermalState: "unknown",
102 stale: true,
103 updatedAt,
104 };
105}
106
107function buildHealth(input: {
108 readonly native: NativeTelemetryClient.NativeTelemetryClientHealth;
109 readonly desktop: DesktopTelemetryReceiver.DesktopTelemetryReceiverHealth;
110 readonly nativeSnapshot: Option.Option<ResourceMonitorSnapshotEvent>;
111}): ResourceTelemetryHealth {
112 return {
113 native: {
114 status: input.native.status,
115 lastSampleAt: input.native.lastSampleAt,
116 lastError: input.native.lastError,
117 },
118 desktop: {
119 status: input.desktop.status,
120 lastSampleAt: input.desktop.lastSampleAt,
121 lastError: input.desktop.lastError,
122 },
123 sidecarVersion: Option.map(input.native.hello, (hello) => hello.sidecarVersion),
124 sidecarPid: Option.map(input.native.hello, (hello) => hello.sidecarPid),
125 restartCount: input.native.restartCount,
126 collectionDurationMicros: Option.match(input.nativeSnapshot, {
127 onNone: () => 0,
128 onSome: (snapshot) => snapshot.collectionDurationMicros,
129 }),
130 scannedProcessCount: Option.match(input.nativeSnapshot, {
131 onNone: () => 0,
132 onSome: (snapshot) => snapshot.scannedProcessCount,
133 }),
134 retainedProcessCount: Option.match(input.nativeSnapshot, {
135 onNone: () => 0,
136 onSome: (snapshot) => snapshot.retainedProcessCount,
137 }),
138 inaccessibleProcessCount: Option.match(input.nativeSnapshot, {
139 onNone: () => 0,
140 onSome: (snapshot) => snapshot.inaccessibleProcessCount,
141 }),
142 };
143}
144
145export const make = Effect.fn("resourceTelemetry.resourceTelemetry.make")(function* () {
146 const nativeClient = yield* NativeTelemetryClient.NativeTelemetryClient;
147 const desktopReceiver = yield* DesktopTelemetryReceiver.DesktopTelemetryReceiver;
148 const attribution = yield* ResourceAttribution.ResourceAttribution;
149 const mutex = yield* Semaphore.make(1);
150 const changes = yield* PubSub.sliding<ResourceTelemetrySnapshot>(8);
151 const initialReadAt = yield* DateTime.now;
152 const nativeHealthSubscription = yield* nativeClient.subscribeHealth;
153 const desktopHealthSubscription = yield* desktopReceiver.subscribeHealth;
154 const desktopSubscription = yield* desktopReceiver.subscribe;
155 const initialDesktop = desktopSubscription.latest;
156 if (Option.isSome(initialDesktop)) {
157 const electronRoot = initialDesktop.value.electronProcesses.find(
158 (process) => process.pid === initialDesktop.value.electronPid,
159 );
160 yield* nativeClient
161 .setExternalProcesses([
162 {
163 pid: initialDesktop.value.electronPid,
164 ...(electronRoot === undefined ? {} : { startTimeMs: electronRoot.creationTimeMs }),
165 },
166 ])
167 .pipe(Effect.ignore);
168 yield* nativeClient.setHostPowerState(initialDesktop.value.power).pipe(Effect.ignore);
169 }
170 const initialNativeHealth = nativeHealthSubscription.latest;
171 const initialDesktopHealth = desktopHealthSubscription.latest;
172 const initialAttribution = yield* attribution.snapshot;
173 const initialMerge = mergeProcesses({
174 serverPid: process.pid,
175 sidecarPid: Option.map(initialNativeHealth.hello, (hello) => hello.sidecarPid),
176 fallbackSampledAtMs: DateTime.toEpochMillis(initialReadAt),
177 nativeSnapshot: Option.none(),
178 desktopSnapshot: initialDesktop,
179 previous: new Map(),
180 counters: emptyTelemetryCounters(),
181 updatePrevious: false,
182 });
183 const initialSnapshot: ResourceTelemetrySnapshot = {
184 readAt: initialReadAt,
185 sampleIntervalMs: initialNativeHealth.sampleIntervalMs,
186 processes: initialMerge.processes,
187 groups: initialMerge.groups,
188 power: Option.match(initialDesktop, {
189 onNone: () => unknownPower(initialReadAt),
190 onSome: (desktop) => desktop.power,
191 }),
192 speedLimitPercent: Option.flatMap(initialDesktop, (desktop) => desktop.speedLimitPercent),
193 attribution: initialAttribution,
194 health: buildHealth({
195 native: initialNativeHealth,
196 desktop: initialDesktopHealth,
197 nativeSnapshot: Option.none(),
198 }),
199 };
200 const state = yield* Ref.make<TelemetryState>({
201 nativeSnapshot: Option.none(),
202 desktopSnapshot: initialDesktop,
203 previous: new Map(),
204 counters: emptyTelemetryCounters(),
205 latest: initialSnapshot,
206 lastNativeSequence: 0,
207 lastNativeGeneration: initialNativeHealth.restartCount,
208 });
209 const liveState = yield* Ref.make<LiveTelemetryState>({
210 retainCount: 0,
211 scope: Option.none(),
212 });
213 const liveMutex = yield* Semaphore.make(1);
214 const refreshHealth = mutex.withPermits(1)(
215 Effect.gen(function* () {
216 const current = yield* Ref.get(state);
217 const [nativeHealth, desktopHealth] = yield* Effect.all([
218 nativeClient.health,
219 desktopReceiver.health,
220 ]);
221 const snapshot: ResourceTelemetrySnapshot = {
222 ...current.latest,
223 health: buildHealth({
224 native: nativeHealth,
225 desktop: desktopHealth,
226 nativeSnapshot: current.nativeSnapshot,
227 }),
228 };
229 yield* Ref.set(state, {
230 ...current,
231 latest: snapshot,
232 });
233 if ((yield* Ref.get(liveState)).retainCount > 0) {
234 yield* PubSub.publish(changes, snapshot);
235 }
236 }),
237 );
238
239 const rebuild = (input: {
240 readonly nativeSnapshot?: NativeTelemetryClient.NativeTelemetrySnapshot;
241 readonly desktopSnapshot?: DesktopHostTelemetrySnapshot;
242 readonly updatePrevious: boolean;
243 readonly publishWhenLive?: boolean;
244 }): Effect.Effect<ResourceTelemetrySnapshot> =>
245 mutex.withPermits(1)(
246 Effect.gen(function* () {
247 const current = yield* Ref.get(state);
248 const nativeHealth = yield* nativeClient.health;
249 const incomingNativeSnapshot = input.nativeSnapshot;
250 if (
251 incomingNativeSnapshot &&
252 (incomingNativeSnapshot.generation < nativeHealth.restartCount ||
253 incomingNativeSnapshot.generation < current.lastNativeGeneration ||
254 (incomingNativeSnapshot.generation === current.lastNativeGeneration &&
255 incomingNativeSnapshot.snapshot.sequence <= current.lastNativeSequence))
256 ) {
257 return current.latest;
258 }
259 const nativeSnapshot = incomingNativeSnapshot
260 ? Option.some(incomingNativeSnapshot.snapshot)
261 : current.nativeSnapshot;
262 const desktopSnapshot = input.desktopSnapshot
263 ? Option.some(input.desktopSnapshot)
264 : current.desktopSnapshot;
265 const [desktopHealth, attributionSnapshot] = yield* Effect.all([
266 desktopReceiver.health,
267 attribution.snapshot,
268 ]);
269 const recordedElectronRoots = Option.match(nativeSnapshot, {
270 onNone: () => [],
271 onSome: (snapshot) => snapshot.externalProcesses ?? [],
272 });
273 const electronRootPids = new Set(recordedElectronRoots.map((process) => process.pid));
274 Option.match(desktopSnapshot, {
275 onNone: () => undefined,
276 onSome: (desktop) => electronRootPids.add(desktop.electronPid),
277 });
278 const electronRootStartTimes = new Map(
279 recordedElectronRoots.flatMap((process) =>
280 process.startTimeMs === undefined ? [] : [[process.pid, process.startTimeMs] as const],
281 ),
282 );
283 const merged = mergeProcesses({
284 serverPid: process.pid,
285 sidecarPid: Option.map(nativeHealth.hello, (hello) => hello.sidecarPid),
286 fallbackSampledAtMs: DateTime.toEpochMillis(current.latest.readAt),
287 nativeSnapshot,
288 desktopSnapshot,
289 electronRootPids,
290 electronRootStartTimes,
291 previous: current.previous,
292 counters: current.counters,
293 updatePrevious: input.updatePrevious,
294 });
295 const readAt = DateTime.makeUnsafe(merged.sampledAtMs);
296 const snapshot: ResourceTelemetrySnapshot = {
297 readAt,
298 sampleIntervalMs: nativeHealth.sampleIntervalMs,
299 processes: merged.processes,
300 groups: merged.groups,
301 power: Option.match(desktopSnapshot, {
302 onNone: () => unknownPower(readAt),
303 onSome: (desktop) => desktop.power,
304 }),
305 speedLimitPercent: Option.match(desktopSnapshot, {
306 onNone: () => Option.none(),
307 onSome: (desktop) => desktop.speedLimitPercent,
308 }),
309 attribution: attributionSnapshot,
310 health: buildHealth({
311 native: nativeHealth,
312 desktop: desktopHealth,
313 nativeSnapshot,
314 }),
315 };
316 yield* Ref.set(state, {
317 nativeSnapshot,
318 desktopSnapshot,
319 previous: merged.previous,
320 counters: merged.counters,
321 latest: snapshot,
322 lastNativeSequence:
323 incomingNativeSnapshot?.snapshot.sequence ?? current.lastNativeSequence,
324 lastNativeGeneration: incomingNativeSnapshot?.generation ?? current.lastNativeGeneration,
325 });
326 if (!input.publishWhenLive || (yield* Ref.get(liveState)).retainCount > 0) {
327 yield* PubSub.publish(changes, snapshot);
328 }
329 return snapshot;
330 }),
331 );
332
333 const ingestNative = (snapshot: NativeTelemetryClient.NativeTelemetrySnapshot) =>
334 rebuild({ nativeSnapshot: snapshot, updatePrevious: true });
335 const ingestDesktop = (snapshot: DesktopHostTelemetrySnapshot) =>
336 Effect.gen(function* () {
337 const electronRoot = snapshot.electronProcesses.find(
338 (process) => process.pid === snapshot.electronPid,
339 );
340 yield* nativeClient
341 .setExternalProcesses([
342 {
343 pid: snapshot.electronPid,
344 ...(electronRoot === undefined ? {} : { startTimeMs: electronRoot.creationTimeMs }),
345 },
346 ])
347 .pipe(Effect.ignore);
348 yield* nativeClient.setHostPowerState(snapshot.power).pipe(Effect.ignore);
349 return yield* rebuild({
350 desktopSnapshot: snapshot,
351 updatePrevious: false,
352 publishWhenLive: true,
353 });
354 });
355
356 yield* desktopSubscription.changes.pipe(
357 Stream.runForEach((snapshot) => ingestDesktop(snapshot)),
358 Effect.forkScoped,
359 );
360
361 const acquireLive = liveMutex.withPermits(1)(
362 Effect.uninterruptible(
363 Effect.gen(function* () {
364 const current = yield* Ref.get(liveState);
365 if (current.retainCount > 0) {
366 yield* Ref.set(liveState, { ...current, retainCount: current.retainCount + 1 });
367 return;
368 }
369
370 const scope = yield* Scope.make();
371 yield* desktopReceiver.setDiagnosticsDemand(true).pipe(Effect.ignore);
372 yield* nativeClient.snapshots.pipe(
373 Stream.runForEach(ingestNative),
374 Effect.catch((error) =>
375 Effect.logWarning("Native resource telemetry stream stopped", {
376 cause: error.message,
377 }),
378 ),
379 Effect.forkIn(scope),
380 );
381 yield* nativeClient.sampleNow.pipe(
382 Effect.flatMap(ingestNative),
383 Effect.ignore,
384 Effect.forkIn(scope),
385 );
386 yield* Ref.set(liveState, { retainCount: 1, scope: Option.some(scope) });
387 }),
388 ),
389 );
390
391 const releaseLive = liveMutex.withPermits(1)(
392 Effect.gen(function* () {
393 const current = yield* Ref.get(liveState);
394 if (current.retainCount <= 1) {
395 yield* Ref.set(liveState, { retainCount: 0, scope: Option.none() });
396 if (Option.isSome(current.scope)) {
397 yield* Scope.close(current.scope.value, Exit.void).pipe(Effect.ignore);
398 }
399 yield* desktopReceiver.setDiagnosticsDemand(false).pipe(Effect.ignore);
400 return;
401 }
402 yield* Ref.set(liveState, { ...current, retainCount: current.retainCount - 1 });
403 }),
404 );
405
406 const latest = Ref.get(state).pipe(Effect.map((current) => current.latest));
407 const subscribe = subscribeBeforeSnapshot(
408 changes,
409 Effect.acquireRelease(acquireLive, () => releaseLive).pipe(Effect.andThen(latest)),
410 mutex,
411 );
412 const liveChanges = Stream.unwrap(Effect.map(subscribe, ({ changes }) => changes));
413
414 const readHistory: ResourceTelemetry["Service"]["readHistory"] = (input) =>
415 Effect.gen(function* () {
416 const readAt = yield* DateTime.now;
417 const normalizedInput = normalizeResourceTelemetryHistoryInput(input);
418 const historyResult = yield* Effect.result(
419 nativeClient.readHistory(normalizedInput.windowMs),
420 );
421 if (Result.isFailure(historyResult)) {
422 yield* Effect.logWarning("Failed to read native resource telemetry history", {
423 cause: historyResult.failure.message,
424 });
425 }
426 const [nativeHealth, desktopHealth] = yield* Effect.all([
427 nativeClient.health,
428 desktopReceiver.health,
429 ]);
430 const current = yield* Ref.get(state);
431 return buildResourceTelemetryHistory({
432 readAt,
433 windowMs: normalizedInput.windowMs,
434 bucketMs: normalizedInput.bucketMs,
435 sampleIntervalMs: nativeHealth.sampleIntervalMs,
436 serverPid: process.pid,
437 sidecarPid: Option.map(nativeHealth.hello, (hello) => hello.sidecarPid),
438 desktopSnapshot: current.desktopSnapshot,
439 snapshots: Result.isSuccess(historyResult) ? historyResult.success : [],
440 health: buildHealth({
441 native: nativeHealth,
442 desktop: desktopHealth,
443 nativeSnapshot: current.nativeSnapshot,
444 }),
445 });
446 });
447 yield* nativeHealthSubscription.changes.pipe(
448 Stream.runForEach(() => refreshHealth),
449 Effect.forkScoped,
450 );
451 yield* desktopHealthSubscription.changes.pipe(
452 Stream.runForEach(() => refreshHealth),
453 Effect.forkScoped,
454 );
455
456 const refresh: ResourceTelemetry["Service"]["refresh"] = nativeClient.sampleNow.pipe(
457 Effect.flatMap(ingestNative),
458 Effect.mapError(
459 (cause) =>
460 new ResourceTelemetryRefreshFailed({
461 operation: "refresh",
462 cause,
463 }),
464 ),
465 );
466
467 const validateProcessIdentity: ResourceTelemetry["Service"]["validateProcessIdentity"] = (
468 identity,
469 ) =>
470 nativeClient.sampleNow.pipe(
471 Effect.map(({ snapshot }) =>
472 snapshot.processes.some(
473 (process) => process.pid === identity.pid && process.startTimeMs === identity.startTimeMs,
474 ),
475 ),
476 Effect.mapError(
477 (cause) =>
478 new ResourceTelemetryRefreshFailed({
479 operation: "validateProcessIdentity",
480 cause,
481 }),
482 ),
483 );
484
485 return ResourceTelemetry.of({
486 latest,
487 changes: liveChanges,
488 subscribe,
489 readHistory,
490 refresh,
491 validateProcessIdentity,
492 retry: nativeClient.retry.pipe(
493 Effect.zip(Ref.get(state)),
494 Effect.map(
495 ([accepted, current]): ResourceTelemetryRetryResult => ({
496 accepted,
497 snapshot: current.latest,
498 }),
499 ),
500 ),
501 });
502});
503
504export const layer = Layer.effect(ResourceTelemetry, make());
apps/server/src/resourceTelemetry/ResourceTelemetryHistory.test.ts created+357
......@@ -0,0 +1,357 @@
1import type {
2 DesktopHostTelemetrySnapshot,
3 ResourceMonitorProcessSample,
4 ResourceMonitorSnapshotEvent,
5 ResourceTelemetryHealth,
6} from "@t3tools/contracts";
7import { describe, expect, it } from "@effect/vitest";
8import * as DateTime from "effect/DateTime";
9import * as Option from "effect/Option";
10
11import {
12 buildResourceTelemetryHistory,
13 normalizeResourceTelemetryHistoryInput,
14} from "./ResourceTelemetryHistory.ts";
15
16const SERVER_PID = 100;
17const ELECTRON_PID = 200;
18const CHILD_PID = 300;
19const STARTED_AT_MS = DateTime.toEpochMillis(DateTime.makeUnsafe("2026-06-17T12:00:00.000Z"));
20
21function processSample(
22 input: Partial<ResourceMonitorProcessSample> &
23 Pick<ResourceMonitorProcessSample, "pid" | "ppid" | "startTimeMs">,
24): ResourceMonitorProcessSample {
25 return {
26 runTimeMs: 1_000,
27 name: `process-${input.pid}`,
28 command: `process-${input.pid}`,
29 status: "Running",
30 cpuPercent: 0,
31 cpuTimeMs: 0,
32 residentBytes: 1_024,
33 virtualBytes: 2_048,
34 ioReadBytes: 0,
35 ioWriteBytes: 0,
36 ioSemantics: "storage",
37 ...input,
38 };
39}
40
41function snapshot(
42 sequence: number,
43 sampledAtUnixMs: number,
44 childCpuTimeMs: number,
45 childWriteBytes: number,
46): ResourceMonitorSnapshotEvent {
47 const processes = [
48 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 10 }),
49 processSample({
50 pid: ELECTRON_PID,
51 ppid: 1,
52 startTimeMs: 20,
53 name: "electron",
54 command: "electron",
55 }),
56 processSample({
57 pid: CHILD_PID,
58 ppid: SERVER_PID,
59 startTimeMs: 30,
60 name: "codex",
61 command: "codex app-server",
62 cpuTimeMs: childCpuTimeMs,
63 ioWriteBytes: childWriteBytes,
64 }),
65 ];
66 return {
67 version: 2,
68 type: "snapshot",
69 sequence,
70 sampledAtUnixMs,
71 collectionDurationMicros: 100,
72 scannedProcessCount: processes.length,
73 retainedProcessCount: processes.length,
74 inaccessibleProcessCount: 0,
75 processes,
76 };
77}
78
79const health: ResourceTelemetryHealth = {
80 native: {
81 status: "healthy",
82 lastSampleAt: Option.none(),
83 lastError: Option.none(),
84 },
85 desktop: {
86 status: "healthy",
87 lastSampleAt: Option.none(),
88 lastError: Option.none(),
89 },
90 sidecarVersion: Option.some("0.1.0"),
91 sidecarPid: Option.some(400),
92 restartCount: 0,
93 collectionDurationMicros: 100,
94 scannedProcessCount: 3,
95 retainedProcessCount: 3,
96 inaccessibleProcessCount: 0,
97};
98
99function desktopSnapshot(): DesktopHostTelemetrySnapshot {
100 const sampledAt = DateTime.makeUnsafe(STARTED_AT_MS + 1_000);
101 return {
102 version: 1,
103 type: "desktopTelemetry",
104 sequence: 1,
105 sampledAtUnixMs: STARTED_AT_MS + 1_000,
106 electronPid: ELECTRON_PID,
107 power: {
108 source: "electron-main",
109 idle: "false",
110 idleSeconds: 0,
111 locked: "false",
112 suspended: false,
113 onBattery: "false",
114 lowPowerMode: "unknown",
115 thermalState: "nominal",
116 stale: false,
117 updatedAt: sampledAt,
118 },
119 speedLimitPercent: Option.none(),
120 electronProcesses: [
121 {
122 pid: ELECTRON_PID,
123 creationTimeMs: 20,
124 type: "Browser",
125 cpuPercent: 999,
126 idleWakeupsPerSecond: 999,
127 workingSetBytes: 999_999,
128 peakWorkingSetBytes: 999_999,
129 },
130 ],
131 };
132}
133
134describe("buildResourceTelemetryHistory", () => {
135 it("normalizes query bounds before requesting native history", () => {
136 expect(normalizeResourceTelemetryHistoryInput({ windowMs: 0, bucketMs: 0 })).toEqual({
137 windowMs: 1_000,
138 bucketMs: 1_000,
139 });
140 });
141
142 it("replays native snapshots on demand without applying current Electron metrics", () => {
143 const history = buildResourceTelemetryHistory({
144 readAt: DateTime.makeUnsafe(STARTED_AT_MS + 2_000),
145 windowMs: 10_000,
146 bucketMs: 10_000,
147 sampleIntervalMs: 1_000,
148 serverPid: SERVER_PID,
149 sidecarPid: Option.some(400),
150 desktopSnapshot: Option.some(desktopSnapshot()),
151 snapshots: [
152 snapshot(1, STARTED_AT_MS, 100, 1_000),
153 snapshot(2, STARTED_AT_MS + 1_000, 350, 5_000),
154 ],
155 health,
156 });
157
158 const child = history.topProcesses.find((process) => process.identity.pid === CHILD_PID);
159 const electron = history.topProcesses.find((process) => process.identity.pid === ELECTRON_PID);
160 expect(child?.sampleCount).toBe(2);
161 expect(child?.cpuTimeMs).toBe(250);
162 expect(child?.ioWriteBytes).toBe(4_000);
163 expect(electron?.category).toBe("electron-main");
164 expect(electron?.currentRssBytes).toBe(1_024);
165 expect(history.buckets.reduce((total, bucket) => total + bucket.ioWriteBytes, 0)).toBe(4_000);
166 });
167
168 it("uses observed RSS for the history-window peak instead of the lifetime process peak", () => {
169 const first = snapshot(1, STARTED_AT_MS, 100, 1_000);
170 const second = snapshot(2, STARTED_AT_MS + 1_000, 200, 2_000);
171 const history = buildResourceTelemetryHistory({
172 readAt: DateTime.makeUnsafe(STARTED_AT_MS + 2_000),
173 windowMs: 10_000,
174 bucketMs: 10_000,
175 sampleIntervalMs: 1_000,
176 serverPid: SERVER_PID,
177 sidecarPid: Option.none(),
178 desktopSnapshot: Option.none(),
179 snapshots: [
180 {
181 ...first,
182 processes: first.processes.map((process) =>
183 process.pid === CHILD_PID
184 ? { ...process, residentBytes: 2_000, peakResidentBytes: 50_000 }
185 : process,
186 ),
187 },
188 {
189 ...second,
190 processes: second.processes.map((process) =>
191 process.pid === CHILD_PID
192 ? { ...process, residentBytes: 3_000, peakResidentBytes: 60_000 }
193 : process,
194 ),
195 },
196 ],
197 health,
198 });
199
200 expect(
201 history.topProcesses.find((process) => process.identity.pid === CHILD_PID)?.peakRssBytes,
202 ).toBe(3_000);
203 });
204
205 it("retains cumulative baselines while a process is absent from an intermediate sample", () => {
206 const first = snapshot(1, STARTED_AT_MS, 100, 1_000);
207 const absent = snapshot(2, STARTED_AT_MS + 1_000, 0, 0);
208 const returned = snapshot(3, STARTED_AT_MS + 2_000, 350, 5_000);
209 const history = buildResourceTelemetryHistory({
210 readAt: DateTime.makeUnsafe(STARTED_AT_MS + 3_000),
211 windowMs: 10_000,
212 bucketMs: 10_000,
213 sampleIntervalMs: 1_000,
214 serverPid: SERVER_PID,
215 sidecarPid: Option.none(),
216 desktopSnapshot: Option.none(),
217 snapshots: [
218 first,
219 {
220 ...absent,
221 processes: absent.processes.filter((process) => process.pid !== CHILD_PID),
222 },
223 returned,
224 ],
225 health,
226 });
227
228 const child = history.topProcesses.find((process) => process.identity.pid === CHILD_PID);
229 expect(child?.cpuTimeMs).toBe(250);
230 expect(child?.ioWriteBytes).toBe(4_000);
231 });
232
233 it("uses an exact current Electron identity for slightly older native samples", () => {
234 const history = buildResourceTelemetryHistory({
235 readAt: DateTime.makeUnsafe(STARTED_AT_MS + 2_000),
236 windowMs: 10_000,
237 bucketMs: 10_000,
238 sampleIntervalMs: 1_000,
239 serverPid: SERVER_PID,
240 sidecarPid: Option.none(),
241 desktopSnapshot: Option.some(desktopSnapshot()),
242 snapshots: [snapshot(1, STARTED_AT_MS, 100, 1_000)],
243 health,
244 });
245
246 expect(
247 history.topProcesses.find((process) => process.identity.pid === ELECTRON_PID)?.category,
248 ).toBe("electron-main");
249 });
250
251 it("uses the preceding sample as a baseline without attributing pre-window deltas", () => {
252 const readAtMs = STARTED_AT_MS + 10_000;
253 const history = buildResourceTelemetryHistory({
254 readAt: DateTime.makeUnsafe(readAtMs),
255 windowMs: 5_000,
256 bucketMs: 5_000,
257 sampleIntervalMs: 1_000,
258 serverPid: SERVER_PID,
259 sidecarPid: Option.none(),
260 desktopSnapshot: Option.none(),
261 snapshots: [
262 snapshot(1, STARTED_AT_MS, 100, 1_000),
263 snapshot(2, STARTED_AT_MS + 5_000, 600, 6_000),
264 snapshot(3, readAtMs + 1_000, 10_000, 20_000),
265 ],
266 health,
267 });
268
269 const child = history.topProcesses.find((process) => process.identity.pid === CHILD_PID);
270 expect(child?.sampleCount).toBe(1);
271 expect(child?.cpuTimeMs).toBe(0);
272 expect(child?.ioWriteBytes).toBe(0);
273 expect(history.buckets.reduce((total, bucket) => total + bucket.ioWriteBytes, 0)).toBe(0);
274 expect(
275 history.buckets.every((bucket) => DateTime.toEpochMillis(bucket.startedAt) <= readAtMs),
276 ).toBe(true);
277 });
278
279 it("prorates a cumulative delta that crosses the history window boundary", () => {
280 const readAtMs = STARTED_AT_MS + 10_000;
281 const history = buildResourceTelemetryHistory({
282 readAt: DateTime.makeUnsafe(readAtMs),
283 windowMs: 5_000,
284 bucketMs: 5_000,
285 sampleIntervalMs: 1_000,
286 serverPid: SERVER_PID,
287 sidecarPid: Option.none(),
288 desktopSnapshot: Option.none(),
289 snapshots: [
290 snapshot(1, STARTED_AT_MS, 100, 1_000),
291 snapshot(2, STARTED_AT_MS + 7_500, 850, 8_500),
292 ],
293 health,
294 });
295
296 const child = history.topProcesses.find((process) => process.identity.pid === CHILD_PID);
297 expect(child?.cpuTimeMs).toBe(250);
298 expect(child?.ioWriteBytes).toBe(2_500);
299 expect(history.buckets.reduce((total, bucket) => total + bucket.ioWriteBytes, 0)).toBe(2_500);
300 });
301
302 it("replays the Electron root identity recorded with each native sample", () => {
303 const oldElectron = snapshot(1, STARTED_AT_MS, 100, 1_000);
304 const restartedElectron = snapshot(2, STARTED_AT_MS + 1_000, 200, 2_000);
305 const history = buildResourceTelemetryHistory({
306 readAt: DateTime.makeUnsafe(STARTED_AT_MS + 2_000),
307 windowMs: 10_000,
308 bucketMs: 10_000,
309 sampleIntervalMs: 1_000,
310 serverPid: SERVER_PID,
311 sidecarPid: Option.none(),
312 desktopSnapshot: Option.none(),
313 snapshots: [
314 {
315 ...oldElectron,
316 externalProcesses: [{ pid: ELECTRON_PID, startTimeMs: 20 }],
317 },
318 {
319 ...restartedElectron,
320 externalProcesses: [{ pid: 201, startTimeMs: 40 }],
321 processes: [
322 ...restartedElectron.processes.filter((process) => process.pid !== ELECTRON_PID),
323 processSample({
324 pid: ELECTRON_PID,
325 ppid: SERVER_PID,
326 startTimeMs: 999,
327 name: "reused",
328 command: "unrelated process",
329 }),
330 processSample({
331 pid: 201,
332 ppid: 1,
333 startTimeMs: 40,
334 name: "electron",
335 command: "electron",
336 }),
337 ],
338 },
339 ],
340 health,
341 });
342
343 expect(
344 history.topProcesses.find(
345 (process) => process.identity.pid === ELECTRON_PID && process.identity.startTimeMs === 20,
346 )?.category,
347 ).toBe("electron-main");
348 expect(
349 history.topProcesses.find(
350 (process) => process.identity.pid === ELECTRON_PID && process.identity.startTimeMs === 999,
351 )?.category,
352 ).toBe("server-child");
353 expect(history.topProcesses.find((process) => process.identity.pid === 201)?.category).toBe(
354 "electron-main",
355 );
356 });
357});
apps/server/src/resourceTelemetry/ResourceTelemetryHistory.ts created+290
......@@ -0,0 +1,290 @@
1import type {
2 DesktopHostTelemetrySnapshot,
3 ResourceMonitorSnapshotEvent,
4 ResourceTelemetryHealth,
5 ResourceTelemetryHistory,
6 ResourceTelemetryHistoryBucket,
7 ResourceTelemetryProcess,
8 ResourceTelemetryProcessSummary,
9} from "@t3tools/contracts";
10import * as DateTime from "effect/DateTime";
11import * as Option from "effect/Option";
12
13import {
14 emptyTelemetryCounters,
15 mergeProcesses,
16 processIdentityKey,
17 type ProcessState,
18 type TelemetryCounters,
19} from "./Model.ts";
20
21const MAX_HISTORY_WINDOW_MS = 60 * 60_000;
22
23export function normalizeResourceTelemetryHistoryInput(input: {
24 readonly windowMs: number;
25 readonly bucketMs: number;
26}): { readonly windowMs: number; readonly bucketMs: number } {
27 const windowMs = Math.max(1_000, Math.min(MAX_HISTORY_WINDOW_MS, input.windowMs));
28 return {
29 windowMs,
30 bucketMs: Math.max(1_000, Math.min(windowMs, input.bucketMs)),
31 };
32}
33
34interface AggregateSample {
35 readonly sampledAtMs: number;
36 readonly cpuPercent: number;
37 readonly rssBytes: number;
38 readonly processCount: number;
39 readonly ioReadBytes: number;
40 readonly ioWriteBytes: number;
41}
42
43interface ProcessSample {
44 readonly sampledAtMs: number;
45 readonly process: ResourceTelemetryProcess;
46 readonly cpuTimeMs: number;
47 readonly ioReadBytes: number;
48 readonly ioWriteBytes: number;
49}
50
51export interface BuildResourceTelemetryHistoryInput {
52 readonly readAt: DateTime.Utc;
53 readonly windowMs: number;
54 readonly bucketMs: number;
55 readonly sampleIntervalMs: number;
56 readonly serverPid: number;
57 readonly sidecarPid: Option.Option<number>;
58 readonly desktopSnapshot: Option.Option<DesktopHostTelemetrySnapshot>;
59 readonly snapshots: ReadonlyArray<ResourceMonitorSnapshotEvent>;
60 readonly health: ResourceTelemetryHealth;
61}
62
63export type ResourceTelemetryHistoryWithLegacyBuckets = ResourceTelemetryHistory & {
64 readonly legacyBackendBuckets?: ReadonlyArray<ResourceTelemetryHistoryBucket>;
65};
66
67function summarizeProcesses(
68 samples: ReadonlyArray<ProcessSample>,
69): ReadonlyArray<ResourceTelemetryProcessSummary> {
70 const groups = new Map<string, ProcessSample[]>();
71 for (const sample of samples) {
72 const identityKey = processIdentityKey(
73 sample.process.identity.pid,
74 sample.process.identity.startTimeMs,
75 );
76 const current = groups.get(identityKey) ?? [];
77 current.push(sample);
78 groups.set(identityKey, current);
79 }
80
81 return [...groups.values()]
82 .map((processSamples): ResourceTelemetryProcessSummary => {
83 const sorted = processSamples.toSorted((left, right) => left.sampledAtMs - right.sampledAtMs);
84 const first = sorted[0]!;
85 const latest = sorted[sorted.length - 1]!;
86 const cpuTotal = sorted.reduce((total, sample) => total + sample.process.cpuPercent, 0);
87 return {
88 identity: latest.process.identity,
89 ppid: latest.process.ppid,
90 depth: latest.process.depth,
91 name: latest.process.name,
92 command: latest.process.command,
93 category: latest.process.category,
94 firstSeenAt: first.process.firstSeenAt,
95 lastSeenAt: latest.process.lastSeenAt,
96 currentCpuPercent: latest.process.cpuPercent,
97 avgCpuPercent: cpuTotal / sorted.length,
98 maxCpuPercent: Math.max(...sorted.map((sample) => sample.process.cpuPercent)),
99 cpuTimeMs: sorted.reduce((total, sample) => total + sample.cpuTimeMs, 0),
100 currentRssBytes: latest.process.residentBytes,
101 peakRssBytes: Math.max(...sorted.map((sample) => sample.process.residentBytes)),
102 ioReadBytes: sorted.reduce((total, sample) => total + sample.ioReadBytes, 0),
103 ioWriteBytes: sorted.reduce((total, sample) => total + sample.ioWriteBytes, 0),
104 ioSemantics: latest.process.ioSemantics,
105 sampleCount: sorted.length,
106 };
107 })
108 .toSorted(
109 (left, right) => right.cpuTimeMs - left.cpuTimeMs || right.peakRssBytes - left.peakRssBytes,
110 );
111}
112
113function buildBuckets(input: {
114 readonly samples: ReadonlyArray<AggregateSample>;
115 readonly nowMs: number;
116 readonly windowMs: number;
117 readonly bucketMs: number;
118}): ReadonlyArray<ResourceTelemetryHistoryBucket> {
119 const windowStartMs = input.nowMs - input.windowMs;
120 const buckets: ResourceTelemetryHistoryBucket[] = [];
121 for (let startedAtMs = windowStartMs; startedAtMs < input.nowMs; startedAtMs += input.bucketMs) {
122 const endedAtMs = Math.min(input.nowMs, startedAtMs + input.bucketMs);
123 const samples = input.samples.filter(
124 (sample) =>
125 sample.sampledAtMs >= startedAtMs &&
126 (endedAtMs === input.nowMs
127 ? sample.sampledAtMs <= endedAtMs
128 : sample.sampledAtMs < endedAtMs),
129 );
130 const cpuTotal = samples.reduce((total, sample) => total + sample.cpuPercent, 0);
131 buckets.push({
132 startedAt: DateTime.makeUnsafe(startedAtMs),
133 endedAt: DateTime.makeUnsafe(endedAtMs),
134 avgCpuPercent: samples.length === 0 ? 0 : cpuTotal / samples.length,
135 maxCpuPercent:
136 samples.length === 0 ? 0 : Math.max(...samples.map((sample) => sample.cpuPercent)),
137 maxRssBytes: samples.length === 0 ? 0 : Math.max(...samples.map((sample) => sample.rssBytes)),
138 ioReadBytes: samples.reduce((total, sample) => total + sample.ioReadBytes, 0),
139 ioWriteBytes: samples.reduce((total, sample) => total + sample.ioWriteBytes, 0),
140 maxProcessCount:
141 samples.length === 0 ? 0 : Math.max(...samples.map((sample) => sample.processCount)),
142 });
143 }
144 return buckets;
145}
146
147export function buildResourceTelemetryHistory(
148 input: BuildResourceTelemetryHistoryInput,
149): ResourceTelemetryHistoryWithLegacyBuckets & {
150 readonly legacyBackendBuckets: ReadonlyArray<ResourceTelemetryHistoryBucket>;
151} {
152 const readAtMs = DateTime.toEpochMillis(input.readAt);
153 const { windowMs, bucketMs } = normalizeResourceTelemetryHistoryInput(input);
154 const windowStartMs = readAtMs - windowMs;
155 const eligibleSnapshots = input.snapshots
156 .filter((snapshot) => snapshot.sampledAtUnixMs <= readAtMs)
157 .toSorted((left, right) => left.sampledAtUnixMs - right.sampledAtUnixMs);
158 const snapshotsInWindow = eligibleSnapshots.filter(
159 (snapshot) => snapshot.sampledAtUnixMs >= windowStartMs,
160 );
161 const precedingSnapshot = eligibleSnapshots.findLast(
162 (snapshot) => snapshot.sampledAtUnixMs < windowStartMs,
163 );
164 const snapshots = precedingSnapshot
165 ? [precedingSnapshot, ...snapshotsInWindow]
166 : snapshotsInWindow;
167 const aggregateSamples: AggregateSample[] = [];
168 const legacyBackendAggregateSamples: AggregateSample[] = [];
169 const processSamples: ProcessSample[] = [];
170 let previous: ReadonlyMap<string, ProcessState> = new Map();
171 let counters: TelemetryCounters = emptyTelemetryCounters();
172 let previousSnapshotAtMs: number | undefined;
173
174 for (const snapshot of snapshots) {
175 const deltaWindowFraction =
176 previousSnapshotAtMs !== undefined &&
177 previousSnapshotAtMs < windowStartMs &&
178 snapshot.sampledAtUnixMs > previousSnapshotAtMs
179 ? Math.max(
180 0,
181 Math.min(
182 1,
183 (snapshot.sampledAtUnixMs - windowStartMs) /
184 (snapshot.sampledAtUnixMs - previousSnapshotAtMs),
185 ),
186 )
187 : 1;
188 previousSnapshotAtMs = snapshot.sampledAtUnixMs;
189 const recordedExternalProcesses =
190 snapshot.externalProcesses ??
191 Option.match(input.desktopSnapshot, {
192 onNone: () => [],
193 onSome: (desktopSnapshot) => [
194 {
195 pid: desktopSnapshot.electronPid,
196 startTimeMs: desktopSnapshot.electronProcesses.find(
197 (metric) => metric.pid === desktopSnapshot.electronPid,
198 )?.creationTimeMs,
199 },
200 ],
201 });
202 const electronRootPids = new Set(recordedExternalProcesses.map((process) => process.pid));
203 const electronRootStartTimes = new Map(
204 recordedExternalProcesses.flatMap((process) =>
205 process.startTimeMs === undefined ? [] : [[process.pid, process.startTimeMs] as const],
206 ),
207 );
208 const merged = mergeProcesses({
209 serverPid: input.serverPid,
210 sidecarPid: input.sidecarPid,
211 fallbackSampledAtMs: snapshot.sampledAtUnixMs,
212 nativeSnapshot: Option.some(snapshot),
213 desktopSnapshot: Option.none(),
214 electronRootPids,
215 electronRootStartTimes,
216 previous,
217 counters,
218 updatePrevious: true,
219 });
220 previous = new Map([...previous, ...merged.previous]);
221 counters = merged.counters;
222 if (snapshot.sampledAtUnixMs < windowStartMs) {
223 continue;
224 }
225 const deltas =
226 deltaWindowFraction === 1
227 ? merged.deltas
228 : merged.deltas.map((delta) => ({
229 ...delta,
230 cpuTimeMs: Math.round(delta.cpuTimeMs * deltaWindowFraction),
231 ioReadBytes: Math.round(delta.ioReadBytes * deltaWindowFraction),
232 ioWriteBytes: Math.round(delta.ioWriteBytes * deltaWindowFraction),
233 }));
234 const deltasByIdentity = new Map(
235 deltas.map((processDelta) => [processDelta.identityKey, processDelta]),
236 );
237 aggregateSamples.push({
238 sampledAtMs: snapshot.sampledAtUnixMs,
239 cpuPercent: merged.groups.allT3.currentCpuPercent,
240 rssBytes: merged.groups.allT3.currentRssBytes,
241 processCount: merged.groups.allT3.processCount,
242 ioReadBytes: deltas.reduce((total, process) => total + process.ioReadBytes, 0),
243 ioWriteBytes: deltas.reduce((total, process) => total + process.ioWriteBytes, 0),
244 });
245 const backendDeltas = deltas.filter(
246 (processDelta) =>
247 processDelta.category === "server" ||
248 processDelta.category === "server-child" ||
249 processDelta.category === "provider-root" ||
250 processDelta.category === "terminal-root",
251 );
252 legacyBackendAggregateSamples.push({
253 sampledAtMs: snapshot.sampledAtUnixMs,
254 cpuPercent: merged.groups.backend.currentCpuPercent,
255 rssBytes: merged.groups.backend.currentRssBytes,
256 processCount: merged.groups.backend.processCount,
257 ioReadBytes: backendDeltas.reduce((total, process) => total + process.ioReadBytes, 0),
258 ioWriteBytes: backendDeltas.reduce((total, process) => total + process.ioWriteBytes, 0),
259 });
260 for (const process of merged.processes) {
261 const processDelta = deltasByIdentity.get(
262 processIdentityKey(process.identity.pid, process.identity.startTimeMs),
263 );
264 processSamples.push({
265 sampledAtMs: snapshot.sampledAtUnixMs,
266 process,
267 cpuTimeMs: processDelta?.cpuTimeMs ?? 0,
268 ioReadBytes: processDelta?.ioReadBytes ?? 0,
269 ioWriteBytes: processDelta?.ioWriteBytes ?? 0,
270 });
271 }
272 }
273
274 return {
275 readAt: input.readAt,
276 windowMs,
277 bucketMs,
278 sampleIntervalMs: input.sampleIntervalMs,
279 retainedSampleCount: aggregateSamples.length + processSamples.length,
280 buckets: buildBuckets({ samples: aggregateSamples, nowMs: readAtMs, windowMs, bucketMs }),
281 legacyBackendBuckets: buildBuckets({
282 samples: legacyBackendAggregateSamples,
283 nowMs: readAtMs,
284 windowMs,
285 bucketMs,
286 }),
287 topProcesses: summarizeProcesses(processSamples),
288 health: input.health,
289 };
290}
apps/server/src/server.test.ts+88
......@@ -71,6 +71,7 @@ import { vi } from "vite-plus/test";
7171
7272const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
7373
74import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
7475import * as ServerConfig from "./config.ts";
7576import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts";
7677import { makeRoutesLayer } from "./server.ts";
......@@ -116,6 +117,10 @@ import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";
116117import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
117118import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
118119import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
120import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts";
121import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClient.ts";
122import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts";
123import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
119124import * as Data from "effect/Data";
120125
121126const defaultProjectId = ProjectId.make("project-default");
......@@ -355,6 +360,10 @@ const buildAppUnderTest = (options?: {
355360 >;
356361 relayClient?: Partial<RelayClient.RelayClient["Service"]>;
357362 cloudCliTokenManager?: Partial<CloudCliTokenManager.CloudCliTokenManager["Service"]>;
363 nativeTelemetryClient?: Partial<NativeTelemetryClient.NativeTelemetryClient["Service"]>;
364 desktopTelemetryReceiver?: Partial<
365 DesktopTelemetryReceiver.DesktopTelemetryReceiver["Service"]
366 >;
358367 };
359368}) =>
360369 Effect.gen(function* () {
......@@ -531,6 +540,15 @@ const buildAppUnderTest = (options?: {
531540 ...options.layers.vcsStatusBroadcaster,
532541 })
533542 : VcsStatusBroadcaster.layer.pipe(Layer.provide(gitWorkflowLayer));
543 const resourceTelemetryLayer = ResourceTelemetry.layer.pipe(
544 Layer.provide(
545 Layer.mergeAll(
546 NativeTelemetryClient.layerTest(options?.layers?.nativeTelemetryClient),
547 DesktopTelemetryReceiver.layerTest(options?.layers?.desktopTelemetryReceiver),
548 ResourceAttribution.layer,
549 ),
550 ),
551 );
534552
535553 const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, {
536554 disableListenLog: true,
......@@ -745,6 +763,7 @@ const buildAppUnderTest = (options?: {
745763 );
746764
747765 const appLayer = servedRoutesLayer.pipe(
766 Layer.provide(resourceTelemetryLayer),
748767 Layer.provide(
749768 Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({
750769 record: () => Effect.void,
......@@ -767,6 +786,58 @@ const buildAppUnderTest = (options?: {
767786 ...options?.layers?.serverRuntimeStartup,
768787 }),
769788 ),
789 Layer.provide(
790 Layer.mock(BackgroundPolicy.BackgroundPolicy)({
791 reportClientActivity: () => Effect.void,
792 removeRpcClient: () => Effect.void,
793 reportHostPowerState: () => Effect.void,
794 snapshot: Effect.succeed({
795 hostPower: {
796 source: "unknown",
797 idle: "unknown",
798 idleSeconds: null,
799 locked: "unknown",
800 suspended: false,
801 onBattery: "unknown",
802 lowPowerMode: "unknown",
803 thermalState: "unknown",
804 stale: true,
805 updatedAt: TEST_EPOCH,
806 },
807 leases: [],
808 activeForegroundLeaseCount: 0,
809 activeScopeKeys: [],
810 shouldRunOpportunisticWork: false,
811 updatedAt: TEST_EPOCH,
812 }),
813 streamChanges: Stream.empty,
814 subscribe: Effect.succeed({
815 latest: {
816 hostPower: {
817 source: "unknown",
818 idle: "unknown",
819 idleSeconds: null,
820 locked: "unknown",
821 suspended: false,
822 onBattery: "unknown",
823 lowPowerMode: "unknown",
824 thermalState: "unknown",
825 stale: true,
826 updatedAt: TEST_EPOCH,
827 },
828 leases: [],
829 activeForegroundLeaseCount: 0,
830 activeScopeKeys: [],
831 shouldRunOpportunisticWork: false,
832 updatedAt: TEST_EPOCH,
833 },
834 changes: Stream.empty,
835 }),
836 hasDemand: () => Effect.succeed(false),
837 shouldRunScopeWork: () => Effect.succeed(false),
838 shouldRunOpportunisticWork: Effect.succeed(false),
839 }),
840 ),
770841 Layer.provide(
771842 Layer.mock(ServerEnvironment.ServerEnvironment)({
772843 getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId),
......@@ -4395,6 +4466,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
43954466 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
43964467 );
43974468
4469 it.effect("routes websocket resource telemetry through the subscription", () =>
4470 Effect.gen(function* () {
4471 yield* buildAppUnderTest();
4472
4473 const wsUrl = yield* getWsServerUrl("/ws");
4474 const snapshot = yield* Effect.scoped(
4475 withWsRpcClient(wsUrl, (client) =>
4476 client[WS_METHODS.subscribeResourceTelemetry]({}).pipe(Stream.runHead),
4477 ),
4478 );
4479
4480 assertTrue(Option.isSome(snapshot));
4481 assert.equal(snapshot.value.processes.length, 0);
4482 assert.equal(snapshot.value.groups.backend.processCount, 0);
4483 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
4484 );
4485
43984486 it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () =>
43994487 Effect.gen(function* () {
44004488 const nextProviders = [
apps/server/src/server.ts+46-6
......@@ -4,6 +4,8 @@ import * as Layer from "effect/Layer";
44import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http";
55import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
66
7import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
8import * as HostPowerMonitor from "./background/HostPowerMonitor.ts";
79import * as ServerConfig from "./config.ts";
810import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts";
911import {
......@@ -90,6 +92,11 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";
9092import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
9193import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
9294import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
95import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts";
96import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClient.ts";
97import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts";
98import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts";
99import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
93100import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
94101import {
95102 clearPersistedServerRuntimeState,
......@@ -106,6 +113,10 @@ import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"
106113// already closes the websocket gracefully. Do not add an artificial drain before
107114// those finalizers get a chance to run.
108115const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0;
116const ResourceAttributionLayerLive = ResourceAttribution.layer;
117const ApplicationObservabilityLive = ObservabilityLive.pipe(
118 Layer.provideMerge(ResourceAttributionLayerLive),
119);
109120
110121const PtyAdapterLive = Layer.unwrap(
111122 Effect.gen(function* () {
......@@ -119,6 +130,35 @@ const PtyAdapterLive = Layer.unwrap(
119130 }),
120131);
121132
133const ServerSettingsLayerLive = ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer));
134
135const NativeTelemetryLayerLive = NativeTelemetryClient.layer.pipe(
136 Layer.provide(ResourceMonitorBinary.layer),
137);
138const DesktopTelemetryReceiverLayerLive = DesktopTelemetryReceiver.layer.pipe(
139 Layer.provideMerge(ServerSettingsLayerLive),
140);
141
142const ResourceTelemetryLayerLive = ResourceTelemetry.layer.pipe(
143 Layer.provideMerge(NativeTelemetryLayerLive),
144 Layer.provideMerge(DesktopTelemetryReceiverLayerLive),
145);
146
147const HostPowerMonitorLayerLive = HostPowerMonitor.layer.pipe(
148 Layer.provide(DesktopTelemetryReceiverLayerLive),
149);
150
151const BackgroundLayerLive = BackgroundPolicy.layer.pipe(
152 Layer.provide(HostPowerMonitorLayerLive),
153 Layer.provideMerge(ServerSettingsLayerLive),
154);
155
156const ResourceDiagnosticsLayerLive = Layer.mergeAll(
157 ResourceTelemetryLayerLive,
158 ProcessDiagnostics.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)),
159 ProcessResourceMonitor.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)),
160);
161
122162const RelayClientLive = Layer.unwrap(
123163 Effect.gen(function* () {
124164 const config = yield* ServerConfig.ServerConfig;
......@@ -184,7 +224,7 @@ const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe(
184224// `ProviderAdapterRegistryLive` is now a facade that resolves kind → adapter
185225// by looking up the default `ProviderInstance` per driver in the instance
186226// registry. Adapter construction itself moved inside each driver's
187// `create()`; `ProviderEventLoggersLive` owns the shared native/canonical
227// `create()`; `ProviderEventLoggers.layer` owns the shared native/canonical
188228// NDJSON writers and is provided at the outer runtime layer so both
189229// `ProviderService` and the per-instance drivers read the same logger pair.
190230const ProviderLayerLive = ProviderServiceLive.pipe(
......@@ -298,6 +338,7 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe(
298338
299339const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
300340 // Core Services
341 Layer.provideMerge(ServerSettingsLayerLive),
301342 Layer.provideMerge(CheckpointingLayerLive),
302343 Layer.provideMerge(SourceControlProviderRegistryLayerLive),
303344 Layer.provideMerge(GitLayerLive),
......@@ -318,14 +359,13 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
318359 // `ProviderService` (canonical stream, written after event normalization).
319360 // Provided once at the runtime level so every consumer sees the same
320361 // logger instances.
321 Layer.provideMerge(ProviderEventLoggers.ProviderEventLoggersLive),
362 Layer.provideMerge(ProviderEventLoggers.layer),
322363 // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old
323364 // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but
324365 // the rewritten registry reads snapshots off the instance registry and
325366 // no longer transitively provides it. Exposing it at the runtime level
326367 // keeps a single Live for all opencode consumers.
327368 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
328 Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))),
329369 Layer.provideMerge(WorkspaceLayerLive),
330370 Layer.provideMerge(ProjectFaviconResolverLayerLive),
331371 Layer.provideMerge(RepositoryIdentityResolver.layer),
......@@ -345,8 +385,8 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
345385
346386const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(
347387 // Misc.
348 Layer.provideMerge(ProcessDiagnostics.layer),
349 Layer.provideMerge(ProcessResourceMonitor.layer),
388 Layer.provideMerge(BackgroundLayerLive),
389 Layer.provideMerge(ResourceDiagnosticsLayerLive),
350390 Layer.provideMerge(TraceDiagnostics.layer),
351391 Layer.provideMerge(AnalyticsService.layer),
352392 Layer.provideMerge(ExternalLauncher.layer),
......@@ -522,7 +562,7 @@ export const makeServerLayer = Layer.unwrap(
522562 Layer.provideMerge(serverRelayBrokerTracingLayer),
523563 Layer.provideMerge(HttpResponseCompressionLive),
524564 Layer.provideMerge(HttpServerLive),
525 Layer.provide(ObservabilityLive),
565 Layer.provide(ApplicationObservabilityLive),
526566 Layer.provideMerge(FetchHttpClient.layer),
527567 Layer.provideMerge(VcsProcess.layer),
528568 Layer.provideMerge(PlatformServicesLive),
apps/server/src/serverSettings.test.ts+33
......@@ -12,8 +12,10 @@ import * as Effect from "effect/Effect";
1212import * as Duration from "effect/Duration";
1313import * as FileSystem from "effect/FileSystem";
1414import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
1516import * as PlatformError from "effect/PlatformError";
1617import * as Schema from "effect/Schema";
18import * as Stream from "effect/Stream";
1719import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
1820import * as ServerConfig from "./config.ts";
1921import * as ServerSettingsModule from "./serverSettings.ts";
......@@ -203,6 +205,29 @@ it.layer(NodeServices.layer)("server settings", (it) => {
203205 }).pipe(Effect.provide(makeServerSettingsLayer())),
204206 );
205207
208 it.effect("buffers changes after a subscription is acquired but before it is consumed", () =>
209 Effect.scoped(
210 Effect.gen(function* () {
211 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
212 const changes = yield* serverSettings.subscribeChanges;
213
214 yield* serverSettings.updateSettings({
215 providers: {
216 codex: {
217 binaryPath: "/usr/local/bin/codex-next",
218 },
219 },
220 });
221
222 const firstChange = yield* changes.pipe(Stream.runHead, Effect.timeout("1 second"));
223 assert.equal(
224 Option.getOrUndefined(firstChange)?.providers.codex.binaryPath,
225 "/usr/local/bin/codex-next",
226 );
227 }),
228 ).pipe(Effect.provide(makeServerSettingsLayer())),
229 );
230
206231 it.effect("preserves model when switching providers via textGenerationModelSelection", () =>
207232 Effect.gen(function* () {
208233 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
......@@ -590,6 +615,14 @@ it.layer(NodeServices.layer)("server settings", (it) => {
590615 serverPassword: "secret-password",
591616 },
592617 },
618 backgroundActivity: {
619 schemaVersion: 1,
620 profile: "custom",
621 baseProfile: "balanced",
622 overrides: {
623 automaticGitFetchInterval: 10_000,
624 },
625 },
593626 automaticGitFetchInterval: 10_000,
594627 });
595628 }).pipe(Effect.provide(makeServerSettingsLayer())),
apps/server/src/serverSettings.ts+37-15
......@@ -131,6 +131,13 @@ export class ServerSettingsService extends Context.Service<
131131
132132 /** Stream of settings change events. */
133133 readonly streamChanges: Stream.Stream<ServerSettings>;
134
135 /**
136 * Acquire a settings change subscription synchronously in the current
137 * fiber. Use this before reading a snapshot when changes between the
138 * snapshot and a lazily started stream must not be lost.
139 */
140 readonly subscribeChanges: Effect.Effect<Stream.Stream<ServerSettings>, never, Scope.Scope>;
134141 }
135142>()("t3/serverSettings/ServerSettingsService") {
136143 /** @deprecated Import and use `layerTest` from this module. */
......@@ -139,13 +146,17 @@ export class ServerSettingsService extends Context.Service<
139146
140147const makeTest = (overrides: DeepPartial<ServerSettings> = {}) =>
141148 Effect.gen(function* () {
142 const { automaticGitFetchInterval, ...overridesForMerge } = overrides;
149 const { automaticGitFetchInterval, providerHealthRefreshInterval, ...overridesForMerge } =
150 overrides;
143151 const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge);
144152 const initialSettings = yield* normalizeServerSettings({
145153 ...merged,
146154 ...(automaticGitFetchInterval !== undefined
147155 ? { automaticGitFetchInterval: automaticGitFetchInterval as Duration.Duration }
148156 : {}),
157 ...(providerHealthRefreshInterval !== undefined
158 ? { providerHealthRefreshInterval: providerHealthRefreshInterval as Duration.Duration }
159 : {}),
149160 });
150161 const currentSettingsRef = yield* Ref.make<ServerSettings>(initialSettings);
151162
......@@ -161,6 +172,7 @@ const makeTest = (overrides: DeepPartial<ServerSettings> = {}) =>
161172 Effect.map(resolveTextGenerationProvider),
162173 ),
163174 streamChanges: Stream.empty,
175 subscribeChanges: Effect.succeed(Stream.empty),
164176 } satisfies ServerSettingsService["Service"];
165177 });
166178
......@@ -197,7 +209,9 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting
197209
198210// Values under these keys are compared as a whole — never stripped field-by-field.
199211const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([
212 "backgroundActivity",
200213 "automaticGitFetchInterval",
214 "providerHealthRefreshInterval",
201215 "sourceControlWriterModelSelection",
202216 "textGenerationModelSelection",
203217]);
......@@ -344,6 +358,23 @@ const make = Effect.gen(function* () {
344358 };
345359 });
346360
361 const materializeChanges = (changes: Stream.Stream<ServerSettings>) =>
362 changes.pipe(
363 Stream.mapEffect((settings) =>
364 materializeProviderEnvironmentSecrets(settings).pipe(
365 Effect.catch((error: ServerSettingsError) =>
366 Effect.logWarning("failed to materialize provider environment secrets", {
367 operation: error.operation,
368 providerInstanceId: error.providerInstanceId,
369 environmentVariable: error.environmentVariable,
370 cause: error.cause,
371 }).pipe(Effect.as(settings)),
372 ),
373 ),
374 ),
375 Stream.map(resolveTextGenerationProvider),
376 );
377
347378 const persistProviderEnvironmentSecrets = (
348379 current: ServerSettings,
349380 next: ServerSettings,
......@@ -561,20 +592,11 @@ const make = Effect.gen(function* () {
561592 }),
562593 ),
563594 get streamChanges() {
564 return Stream.fromPubSub(changesPubSub).pipe(
565 Stream.mapEffect((settings) =>
566 materializeProviderEnvironmentSecrets(settings).pipe(
567 Effect.catch((error: ServerSettingsError) =>
568 Effect.logWarning("failed to materialize provider environment secrets", {
569 operation: error.operation,
570 providerInstanceId: error.providerInstanceId,
571 environmentVariable: error.environmentVariable,
572 cause: error.cause,
573 }).pipe(Effect.as(settings)),
574 ),
575 ),
576 ),
577 Stream.map(resolveTextGenerationProvider),
595 return materializeChanges(Stream.fromPubSub(changesPubSub));
596 },
597 get subscribeChanges() {
598 return PubSub.subscribe(changesPubSub).pipe(
599 Effect.map((subscription) => materializeChanges(Stream.fromSubscription(subscription))),
578600 );
579601 },
580602 } satisfies ServerSettingsService["Service"];
apps/server/src/utils/subscribeBeforeSnapshot.test.ts created+84
......@@ -0,0 +1,84 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as Option from "effect/Option";
6import * as PubSub from "effect/PubSub";
7import * as Ref from "effect/Ref";
8import * as Semaphore from "effect/Semaphore";
9import * as Stream from "effect/Stream";
10
11import {
12 subscribeBeforeSnapshot,
13 subscribeBeforeSnapshotWithoutMutex,
14} from "./subscribeBeforeSnapshot.ts";
15
16describe("subscribeBeforeSnapshot", () => {
17 it.effect("atomically reads the initial snapshot before receiving later changes", () =>
18 Effect.scoped(
19 Effect.gen(function* () {
20 const changes = yield* PubSub.sliding<number>(1);
21 const latest = yield* Ref.make(1);
22 const mutex = yield* Semaphore.make(1);
23 const snapshotStarted = yield* Deferred.make<void>();
24 const finishSnapshot = yield* Deferred.make<void>();
25 const subscriptionFiber = yield* subscribeBeforeSnapshot(
26 changes,
27 Deferred.succeed(snapshotStarted, undefined).pipe(
28 Effect.andThen(Deferred.await(finishSnapshot)),
29 Effect.andThen(Ref.get(latest)),
30 ),
31 mutex,
32 ).pipe(Effect.forkChild);
33
34 yield* Deferred.await(snapshotStarted);
35 const publishFiber = yield* mutex
36 .withPermits(1)(Ref.set(latest, 2).pipe(Effect.andThen(PubSub.publish(changes, 2))))
37 .pipe(Effect.forkChild);
38 yield* Deferred.succeed(finishSnapshot, undefined);
39
40 const subscription = yield* Fiber.join(subscriptionFiber);
41 yield* Fiber.join(publishFiber);
42 const firstChange = yield* subscription.changes.pipe(
43 Stream.runHead,
44 Effect.timeout("1 second"),
45 );
46
47 expect(subscription.latest).toBe(1);
48 expect(firstChange).toEqual(Option.some(2));
49 }),
50 ),
51 );
52
53 it.effect("subscribes before an uncoordinated snapshot can change", () =>
54 Effect.scoped(
55 Effect.gen(function* () {
56 const changes = yield* PubSub.sliding<number>(1);
57 const latest = yield* Ref.make(1);
58 const snapshotStarted = yield* Deferred.make<void>();
59 const finishSnapshot = yield* Deferred.make<void>();
60 const subscriptionFiber = yield* subscribeBeforeSnapshotWithoutMutex(
61 changes,
62 Deferred.succeed(snapshotStarted, undefined).pipe(
63 Effect.andThen(Deferred.await(finishSnapshot)),
64 Effect.andThen(Ref.get(latest)),
65 ),
66 ).pipe(Effect.forkChild);
67
68 yield* Deferred.await(snapshotStarted);
69 yield* Ref.set(latest, 2);
70 yield* PubSub.publish(changes, 2);
71 yield* Deferred.succeed(finishSnapshot, undefined);
72
73 const subscription = yield* Fiber.join(subscriptionFiber);
74 const firstChange = yield* subscription.changes.pipe(
75 Stream.runHead,
76 Effect.timeout("1 second"),
77 );
78
79 expect(subscription.latest).toBe(2);
80 expect(firstChange).toEqual(Option.some(2));
81 }),
82 ),
83 );
84});
apps/server/src/utils/subscribeBeforeSnapshot.ts created+37
......@@ -0,0 +1,37 @@
1import * as Effect from "effect/Effect";
2import * as PubSub from "effect/PubSub";
3import * as Semaphore from "effect/Semaphore";
4import * as Stream from "effect/Stream";
5
6export interface SnapshotSubscription<A> {
7 readonly latest: A;
8 readonly changes: Stream.Stream<A>;
9}
10
11export const subscribeBeforeSnapshot = Effect.fn("subscribeBeforeSnapshot")(function* <A, E, R>(
12 changes: PubSub.PubSub<A>,
13 snapshot: Effect.Effect<A, E, R>,
14 mutex: Semaphore.Semaphore,
15) {
16 return yield* mutex.withPermits(1)(
17 Effect.gen(function* () {
18 const latest = yield* snapshot;
19 const subscription = yield* PubSub.subscribe(changes);
20 return {
21 latest,
22 changes: Stream.fromSubscription(subscription),
23 } satisfies SnapshotSubscription<A>;
24 }),
25 );
26});
27
28export const subscribeBeforeSnapshotWithoutMutex = Effect.fn("subscribeBeforeSnapshotWithoutMutex")(
29 function* <A, E, R>(changes: PubSub.PubSub<A>, snapshot: Effect.Effect<A, E, R>) {
30 const subscription = yield* PubSub.subscribe(changes);
31 const latest = yield* snapshot;
32 return {
33 latest,
34 changes: Stream.fromSubscription(subscription),
35 } satisfies SnapshotSubscription<A>;
36 },
37);
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+92
......@@ -2,6 +2,7 @@ import { assert, it, describe } from "@effect/vitest";
22import * as NodeServices from "@effect/platform-node/NodeServices";
33import * as Cause from "effect/Cause";
44import * as Deferred from "effect/Deferred";
5import * as DateTime from "effect/DateTime";
56import * as Duration from "effect/Duration";
67import * as Effect from "effect/Effect";
78import * as Exit from "effect/Exit";
......@@ -14,6 +15,7 @@ import * as Scope from "effect/Scope";
1415import * as Stream from "effect/Stream";
1516import * as TestClock from "effect/testing/TestClock";
1617import type {
18 BackgroundScope,
1719 VcsStatusLocalResult,
1820 VcsStatusRemoteResult,
1921 VcsStatusResult,
......@@ -22,8 +24,11 @@ import type {
2224import { GitManagerError } from "@t3tools/contracts";
2325
2426import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts";
27import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
2528import * as GitWorkflowService from "../git/GitWorkflowService.ts";
2629
30const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
31
2732const baseLocalStatus: VcsStatusLocalResult = {
2833 isRepo: true,
2934 sourceControlProvider: {
......@@ -73,6 +78,7 @@ function makeTestLayer(state: {
7378}) {
7479 return VcsStatusBroadcaster.layer.pipe(
7580 Layer.provideMerge(NodeServices.layer),
81 Layer.provide(makeBackgroundPolicyLayer(() => true)),
7682 Layer.provide(
7783 Layer.mock(GitWorkflowService.GitWorkflowService)({
7884 localStatus: () =>
......@@ -104,6 +110,37 @@ function makeTestLayer(state: {
104110 );
105111}
106112
113function makeBackgroundPolicyLayer(shouldRunScopeWork: (scope: BackgroundScope) => boolean) {
114 return Layer.mock(BackgroundPolicy.BackgroundPolicy)({
115 reportClientActivity: () => Effect.void,
116 removeRpcClient: () => Effect.void,
117 reportHostPowerState: () => Effect.void,
118 snapshot: Effect.succeed({
119 hostPower: {
120 source: "unknown",
121 idle: "unknown",
122 idleSeconds: null,
123 locked: "unknown",
124 suspended: false,
125 onBattery: "unknown",
126 lowPowerMode: "unknown",
127 thermalState: "unknown",
128 stale: true,
129 updatedAt: TEST_EPOCH,
130 },
131 leases: [],
132 activeForegroundLeaseCount: 0,
133 activeScopeKeys: [],
134 shouldRunOpportunisticWork: false,
135 updatedAt: TEST_EPOCH,
136 }),
137 streamChanges: Stream.empty,
138 hasDemand: () => Effect.succeed(true),
139 shouldRunScopeWork: (scope) => Effect.sync(() => shouldRunScopeWork(scope)),
140 shouldRunOpportunisticWork: Effect.succeed(true),
141 });
142}
143
107144describe("VcsStatusBroadcaster", () => {
108145 it.effect("reuses the cached VCS status across repeated reads", () => {
109146 const state = {
......@@ -183,6 +220,7 @@ describe("VcsStatusBroadcaster", () => {
183220 };
184221 const testLayer = VcsStatusBroadcaster.layer.pipe(
185222 Layer.provideMerge(NodeServices.layer),
223 Layer.provide(makeBackgroundPolicyLayer(() => true)),
186224 Layer.provide(
187225 Layer.mock(GitWorkflowService.GitWorkflowService)({
188226 localStatus: () =>
......@@ -290,6 +328,7 @@ describe("VcsStatusBroadcaster", () => {
290328 };
291329 const testLayer = VcsStatusBroadcaster.layer.pipe(
292330 Layer.provideMerge(NodeServices.layer),
331 Layer.provide(makeBackgroundPolicyLayer(() => true)),
293332 Layer.provide(
294333 Layer.mock(GitWorkflowService.GitWorkflowService)({
295334 localStatus: (input) =>
......@@ -453,6 +492,7 @@ describe("VcsStatusBroadcaster", () => {
453492 let firstRemoteAttemptDeferred: Deferred.Deferred<void> | null = null;
454493 const testLayer = VcsStatusBroadcaster.layer.pipe(
455494 Layer.provideMerge(NodeServices.layer),
495 Layer.provide(makeBackgroundPolicyLayer(() => true)),
456496 Layer.provide(
457497 Layer.mock(GitWorkflowService.GitWorkflowService)({
458498 localStatus: () =>
......@@ -641,6 +681,57 @@ describe("VcsStatusBroadcaster", () => {
641681 });
642682 });
643683
684 it.effect("does not start automatic remote refreshes without foreground client demand", () => {
685 const state = {
686 currentLocalStatus: baseLocalStatus,
687 currentRemoteStatus: baseRemoteStatus,
688 localStatusCalls: 0,
689 remoteStatusCalls: 0,
690 localInvalidationCalls: 0,
691 remoteInvalidationCalls: 0,
692 };
693 const testLayer = VcsStatusBroadcaster.layer.pipe(
694 Layer.provideMerge(NodeServices.layer),
695 Layer.provide(makeBackgroundPolicyLayer(() => false)),
696 Layer.provide(
697 Layer.mock(GitWorkflowService.GitWorkflowService)({
698 localStatus: () =>
699 Effect.sync(() => {
700 state.localStatusCalls += 1;
701 return state.currentLocalStatus;
702 }),
703 remoteStatus: () =>
704 Effect.sync(() => {
705 state.remoteStatusCalls += 1;
706 return state.currentRemoteStatus;
707 }),
708 invalidateLocalStatus: () =>
709 Effect.sync(() => {
710 state.localInvalidationCalls += 1;
711 }),
712 invalidateRemoteStatus: () =>
713 Effect.sync(() => {
714 state.remoteInvalidationCalls += 1;
715 }),
716 } satisfies Partial<GitWorkflowService.GitWorkflowService["Service"]>),
717 ),
718 );
719
720 return Effect.gen(function* () {
721 const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
722 const snapshot = yield* Stream.runHead(
723 broadcaster.streamStatus(
724 { cwd: "/repo" },
725 { automaticRemoteRefreshInterval: Effect.succeed(Duration.seconds(1)) },
726 ),
727 );
728
729 assert.isTrue(Option.isSome(snapshot));
730 assert.equal(state.remoteStatusCalls, 0);
731 assert.equal(state.remoteInvalidationCalls, 0);
732 }).pipe(Effect.provide(testLayer));
733 });
734
644735 it.effect("stops the remote poller after the last stream subscriber disconnects", () => {
645736 const state = {
646737 currentLocalStatus: baseLocalStatus,
......@@ -654,6 +745,7 @@ describe("VcsStatusBroadcaster", () => {
654745 let remoteStartedDeferred: Deferred.Deferred<void, never> | null = null;
655746 const testLayer = VcsStatusBroadcaster.layer.pipe(
656747 Layer.provideMerge(NodeServices.layer),
748 Layer.provide(makeBackgroundPolicyLayer(() => true)),
657749 Layer.provide(
658750 Layer.mock(GitWorkflowService.GitWorkflowService)({
659751 localStatus: () =>
apps/server/src/vcs/VcsStatusBroadcaster.ts+82-28
......@@ -22,6 +22,7 @@ import type {
2222} from "@t3tools/contracts";
2323import { mergeGitStatusParts } from "@t3tools/shared/git";
2424
25import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
2526import * as GitWorkflowService from "../git/GitWorkflowService.ts";
2627
2728const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30);
......@@ -131,6 +132,7 @@ interface CachedVcsStatus {
131132interface ActiveRemotePoller {
132133 readonly fiber: Fiber.Fiber<void, never>;
133134 readonly subscriberCount: number;
135 readonly demandCwds: Ref.Ref<ReadonlyMap<string, number>>;
134136}
135137
136138interface StreamStatusOptions {
......@@ -180,6 +182,7 @@ const normalizeCwd = (cwd: string) =>
180182
181183export const make = Effect.gen(function* () {
182184 const workflow = yield* GitWorkflowService.GitWorkflowService;
185 const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;
183186 const fs = yield* FileSystem.FileSystem;
184187 const changesPubSub = yield* Effect.acquireRelease(
185188 PubSub.unbounded<VcsStatusChange>(),
......@@ -378,6 +381,7 @@ export const make = Effect.gen(function* () {
378381
379382 const makeRemoteRefreshLoop = (
380383 cwd: string,
384 demandCwdsRef: Ref.Ref<ReadonlyMap<string, number>>,
381385 automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>,
382386 refreshImmediately: boolean,
383387 ) => {
......@@ -394,6 +398,22 @@ export const make = Effect.gen(function* () {
394398 return activeInterval;
395399 }
396400
401 const demandCwds = yield* Ref.get(demandCwdsRef);
402 const shouldRun =
403 needsInitialRefresh ||
404 (yield* Effect.all(
405 [...demandCwds.keys()].map((demandCwd) =>
406 backgroundPolicy.shouldRunScopeWork({
407 type: "vcs-status",
408 cwd: demandCwd,
409 }),
410 ),
411 { concurrency: "unbounded" },
412 )).some(Boolean);
413 if (!shouldRun) {
414 return activeInterval;
415 }
416
397417 const exit = yield* refreshRemoteStatus(cwd, {
398418 refreshUpstream: !Duration.isZero(configuredInterval),
399419 }).pipe(Effect.exit);
......@@ -444,55 +464,88 @@ export const make = Effect.gen(function* () {
444464
445465 const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* (
446466 cwd: string,
467 demandCwd: string,
447468 automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>,
448469 refreshImmediately: boolean,
449470 ) {
450471 yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => {
451472 const existing = activePollers.get(cwd);
452473 if (existing) {
453 const nextPollers = new Map(activePollers);
454 nextPollers.set(cwd, {
455 ...existing,
456 subscriberCount: existing.subscriberCount + 1,
457 });
458 return Effect.succeed([undefined, nextPollers] as const);
474 return Ref.update(existing.demandCwds, (demandCwds) => {
475 const next = new Map(demandCwds);
476 next.set(demandCwd, (next.get(demandCwd) ?? 0) + 1);
477 return next;
478 }).pipe(
479 Effect.map(() => {
480 const nextPollers = new Map(activePollers);
481 nextPollers.set(cwd, {
482 ...existing,
483 subscriberCount: existing.subscriberCount + 1,
484 });
485 return [undefined, nextPollers] as const;
486 }),
487 );
459488 }
460489
461 return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval, refreshImmediately).pipe(
462 Effect.forkIn(broadcasterScope),
463 Effect.map((fiber) => {
464 const nextPollers = new Map(activePollers);
465 nextPollers.set(cwd, {
466 fiber,
467 subscriberCount: 1,
468 });
469 return [undefined, nextPollers] as const;
470 }),
490 return Ref.make<ReadonlyMap<string, number>>(new Map([[demandCwd, 1]])).pipe(
491 Effect.flatMap((demandCwds) =>
492 makeRemoteRefreshLoop(
493 cwd,
494 demandCwds,
495 automaticRemoteRefreshInterval,
496 refreshImmediately,
497 ).pipe(
498 Effect.forkIn(broadcasterScope),
499 Effect.map((fiber) => {
500 const nextPollers = new Map(activePollers);
501 nextPollers.set(cwd, {
502 fiber,
503 subscriberCount: 1,
504 demandCwds,
505 });
506 return [undefined, nextPollers] as const;
507 }),
508 ),
509 ),
471510 );
472511 });
473512 });
474513
475514 const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* (
476515 cwd: string,
516 demandCwd: string,
477517 ) {
478 const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => {
518 const pollerToInterrupt = yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => {
479519 const existing = activePollers.get(cwd);
480520 if (!existing) {
481 return [null, activePollers] as const;
521 return Effect.succeed([null, activePollers] as const);
482522 }
483523
484524 if (existing.subscriberCount > 1) {
485 const nextPollers = new Map(activePollers);
486 nextPollers.set(cwd, {
487 ...existing,
488 subscriberCount: existing.subscriberCount - 1,
489 });
490 return [null, nextPollers] as const;
525 return Ref.update(existing.demandCwds, (demandCwds) => {
526 const nextDemandCwds = new Map(demandCwds);
527 const count = nextDemandCwds.get(demandCwd) ?? 0;
528 if (count <= 1) {
529 nextDemandCwds.delete(demandCwd);
530 } else {
531 nextDemandCwds.set(demandCwd, count - 1);
532 }
533 return nextDemandCwds;
534 }).pipe(
535 Effect.as([
536 null,
537 new Map(activePollers).set(cwd, {
538 ...existing,
539 subscriberCount: existing.subscriberCount - 1,
540 }),
541 ] as const),
542 );
491543 }
492544
493 const nextPollers = new Map(activePollers);
494 nextPollers.delete(cwd);
495 return [existing.fiber, nextPollers] as const;
545 return Effect.succeed([
546 existing.fiber,
547 new Map([...activePollers].filter(([activeCwd]) => activeCwd !== cwd)),
548 ] as const);
496549 });
497550
498551 if (pollerToInterrupt) {
......@@ -510,12 +563,13 @@ export const make = Effect.gen(function* () {
510563 const initialRemote = cachedStatus?.remote?.value ?? null;
511564 yield* retainRemotePoller(
512565 cwd,
566 input.cwd,
513567 options?.automaticRemoteRefreshInterval ??
514568 Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL),
515569 cachedStatus?.remote === null || cachedStatus?.remote === undefined,
516570 );
517571
518 const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid);
572 const release = releaseRemotePoller(cwd, input.cwd).pipe(Effect.ignore, Effect.asVoid);
519573
520574 return Stream.concat(
521575 Stream.make({
apps/server/src/ws.ts+88-89
......@@ -11,12 +11,6 @@ import * as Schema from "effect/Schema";
1111import * as Stream from "effect/Stream";
1212import {
1313 DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL,
14 AuthOrchestrationOperateScope,
15 AuthOrchestrationReadScope,
16 AuthReviewWriteScope,
17 AuthRelayWriteScope,
18 AuthTerminalOperateScope,
19 AuthAccessReadScope,
2014 AuthAccessStreamError,
2115 type AuthAccessStreamEvent,
2216 type AuthEnvironmentScope,
......@@ -50,6 +44,7 @@ import {
5044 FilesystemBrowseError,
5145 AssetWorkspaceContextNotFoundError,
5246 AssetWorkspaceContextResolutionError,
47 RpcClientId,
5348 EnvironmentAuthorizationError,
5449 ThreadId,
5550 type TerminalAttachStreamEvent,
......@@ -59,6 +54,7 @@ import {
5954 WS_METHODS,
6055 WsRpcGroup,
6156} from "@t3tools/contracts";
57import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
6258import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http";
6359import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
6460
......@@ -98,9 +94,12 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts";
9894import * as ReviewService from "./review/ReviewService.ts";
9995import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
10096import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
97import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
10198import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
99import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts";
102100import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
103101import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
102import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
104103import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
105104import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts";
106105import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
......@@ -295,78 +294,6 @@ const PROVIDER_STATUS_DEBOUNCE_MS = 200;
295294// Matches the event store's default page size (DEFAULT_READ_FROM_SEQUENCE_LIMIT).
296295const SHELL_RESUME_MAX_GAP = 1_000;
297296
298const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([
299 [ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope],
300 [ORCHESTRATION_WS_METHODS.getTurnDiff, AuthOrchestrationReadScope],
301 [ORCHESTRATION_WS_METHODS.getFullThreadDiff, AuthOrchestrationReadScope],
302 [ORCHESTRATION_WS_METHODS.subscribeShell, AuthOrchestrationReadScope],
303 [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope],
304 [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope],
305 [WS_METHODS.serverProbe, AuthOrchestrationReadScope],
306 [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope],
307 [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope],
308 [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope],
309 [WS_METHODS.serverUpdateServer, AuthOrchestrationOperateScope],
310 [WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope],
311 [WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope],
312 [WS_METHODS.serverGetSettings, AuthOrchestrationReadScope],
313 [WS_METHODS.serverUpdateSettings, AuthOrchestrationOperateScope],
314 [WS_METHODS.serverDiscoverSourceControl, AuthOrchestrationReadScope],
315 [WS_METHODS.serverGetTraceDiagnostics, AuthOrchestrationReadScope],
316 [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope],
317 [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope],
318 [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope],
319 [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope],
320 [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope],
321 [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope],
322 [WS_METHODS.sourceControlCloneRepository, AuthOrchestrationOperateScope],
323 [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope],
324 [WS_METHODS.projectsListEntries, AuthOrchestrationReadScope],
325 [WS_METHODS.projectsReadFile, AuthOrchestrationReadScope],
326 [WS_METHODS.projectsSearchEntries, AuthOrchestrationReadScope],
327 [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope],
328 [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope],
329 [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope],
330 [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope],
331 [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope],
332 [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope],
333 [WS_METHODS.vcsPull, AuthOrchestrationOperateScope],
334 [WS_METHODS.gitRunStackedAction, AuthOrchestrationOperateScope],
335 [WS_METHODS.gitResolvePullRequest, AuthOrchestrationOperateScope],
336 [WS_METHODS.gitPreparePullRequestThread, AuthOrchestrationOperateScope],
337 [WS_METHODS.vcsListRefs, AuthOrchestrationReadScope],
338 [WS_METHODS.vcsCreateWorktree, AuthOrchestrationOperateScope],
339 [WS_METHODS.vcsRemoveWorktree, AuthOrchestrationOperateScope],
340 [WS_METHODS.vcsCreateRef, AuthOrchestrationOperateScope],
341 [WS_METHODS.vcsSwitchRef, AuthOrchestrationOperateScope],
342 [WS_METHODS.vcsInit, AuthOrchestrationOperateScope],
343 [WS_METHODS.reviewGetDiffPreview, AuthReviewWriteScope],
344 [WS_METHODS.terminalOpen, AuthTerminalOperateScope],
345 [WS_METHODS.terminalAttach, AuthTerminalOperateScope],
346 [WS_METHODS.terminalWrite, AuthTerminalOperateScope],
347 [WS_METHODS.terminalResize, AuthTerminalOperateScope],
348 [WS_METHODS.terminalClear, AuthTerminalOperateScope],
349 [WS_METHODS.terminalRestart, AuthTerminalOperateScope],
350 [WS_METHODS.terminalClose, AuthTerminalOperateScope],
351 [WS_METHODS.subscribeTerminalEvents, AuthTerminalOperateScope],
352 [WS_METHODS.subscribeTerminalMetadata, AuthTerminalOperateScope],
353 [WS_METHODS.previewOpen, AuthOrchestrationOperateScope],
354 [WS_METHODS.previewNavigate, AuthOrchestrationOperateScope],
355 [WS_METHODS.previewResize, AuthOrchestrationOperateScope],
356 [WS_METHODS.previewRefresh, AuthOrchestrationOperateScope],
357 [WS_METHODS.previewClose, AuthOrchestrationOperateScope],
358 [WS_METHODS.previewList, AuthOrchestrationReadScope],
359 [WS_METHODS.previewReportStatus, AuthOrchestrationOperateScope],
360 [WS_METHODS.previewAutomationConnect, AuthOrchestrationOperateScope],
361 [WS_METHODS.previewAutomationRespond, AuthOrchestrationOperateScope],
362 [WS_METHODS.previewAutomationFocusHost, AuthOrchestrationOperateScope],
363 [WS_METHODS.subscribePreviewEvents, AuthOrchestrationReadScope],
364 [WS_METHODS.subscribeDiscoveredLocalServers, AuthOrchestrationReadScope],
365 [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope],
366 [WS_METHODS.subscribeServerLifecycle, AuthOrchestrationReadScope],
367 [WS_METHODS.subscribeAuthAccess, AuthAccessReadScope],
368]);
369
370297function toAuthAccessStreamEvent(
371298 change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange,
372299 revision: number,
......@@ -438,10 +365,28 @@ const makeWsRpcLayer = (
438365 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
439366 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
440367 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
368 const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;
369 const rpcClientIds = yield* Ref.make(new Set<RpcClientId>());
370 yield* Effect.addFinalizer(() =>
371 Ref.get(rpcClientIds).pipe(
372 Effect.flatMap((clientIds) =>
373 Effect.forEach(
374 clientIds,
375 (clientId) => backgroundPolicy.removeRpcClient(currentSessionId, clientId),
376 {
377 discard: true,
378 },
379 ),
380 ),
381 Effect.ignore,
382 ),
383 );
441384 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
442385 const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery;
443386 const automaticGitFetchInterval = serverSettings.getSettings.pipe(
444 Effect.map((settings) => settings.automaticGitFetchInterval),
387 Effect.map(
388 (settings) => resolveServerBackgroundActivitySettings(settings).automaticGitFetchInterval,
389 ),
445390 Effect.catch((cause) =>
446391 Effect.logWarning("Failed to read automatic Git fetch interval setting", {
447392 detail: cause.message,
......@@ -454,6 +399,7 @@ const makeWsRpcLayer = (
454399 const sessions = yield* SessionStore.SessionStore;
455400 const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;
456401 const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor;
402 const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry;
457403 const relayClient = yield* RelayClient.RelayClient;
458404 const authorizationError = (requiredScope: AuthEnvironmentScope) =>
459405 new EnvironmentAuthorizationError({
......@@ -474,13 +420,6 @@ const makeWsRpcLayer = (
474420 currentSession.scopes.includes(requiredScope)
475421 ? stream
476422 : Stream.fail(authorizationError(requiredScope));
477 const requiredScopeForMethod = (method: string): AuthEnvironmentScope => {
478 const requiredScope = RPC_REQUIRED_SCOPE.get(method);
479 if (requiredScope === undefined) {
480 throw new Error(`RPC method ${method} has no declared authorization scope.`);
481 }
482 return requiredScope;
483 };
484423 const observeRpcEffect = <A, E, R>(
485424 method: string,
486425 effect: Effect.Effect<A, E, R>,
......@@ -488,7 +427,7 @@ const makeWsRpcLayer = (
488427 ) =>
489428 instrumentRpcEffect(
490429 method,
491 authorizeEffect(requiredScopeForMethod(method), effect),
430 authorizeEffect(requiredScopeForRpcMethod(method), effect),
492431 traceAttributes,
493432 );
494433 const observeRpcStream = <A, E, R>(
......@@ -498,7 +437,7 @@ const makeWsRpcLayer = (
498437 ) =>
499438 instrumentRpcStream(
500439 method,
501 authorizeStream(requiredScopeForMethod(method), stream),
440 authorizeStream(requiredScopeForRpcMethod(method), stream),
502441 traceAttributes,
503442 );
504443 const observeRpcStreamEffect = <A, StreamError, StreamContext, EffectError, EffectContext>(
......@@ -512,7 +451,7 @@ const makeWsRpcLayer = (
512451 ) =>
513452 instrumentRpcStreamEffect(
514453 method,
515 authorizeEffect(requiredScopeForMethod(method), effect),
454 authorizeEffect(requiredScopeForRpcMethod(method), effect),
516455 traceAttributes,
517456 );
518457 const toDispatchCommandError = (cause: unknown, fallbackMessage: string) =>
......@@ -1494,10 +1433,50 @@ const makeWsRpcLayer = (
14941433 "rpc.aggregate": "server",
14951434 },
14961435 ),
1436 [WS_METHODS.serverGetResourceTelemetryHistory]: (input) =>
1437 observeRpcEffect(
1438 WS_METHODS.serverGetResourceTelemetryHistory,
1439 resourceTelemetry.readHistory(input),
1440 {
1441 "rpc.aggregate": "server",
1442 },
1443 ),
1444 [WS_METHODS.serverRetryResourceTelemetry]: (_input) =>
1445 observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, {
1446 "rpc.aggregate": "server",
1447 }),
14971448 [WS_METHODS.serverSignalProcess]: (input) =>
14981449 observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), {
14991450 "rpc.aggregate": "server",
15001451 }),
1452 [WS_METHODS.serverReportClientActivity]: (input, metadata) =>
1453 Ref.update(rpcClientIds, (clientIds) => {
1454 const next = new Set(clientIds);
1455 next.add(RpcClientId.make(metadata.client.id));
1456 return next;
1457 }).pipe(
1458 Effect.andThen(
1459 observeRpcEffect(
1460 WS_METHODS.serverReportClientActivity,
1461 backgroundPolicy.reportClientActivity(
1462 currentSessionId,
1463 RpcClientId.make(metadata.client.id),
1464 input,
1465 ),
1466 { "rpc.aggregate": "server" },
1467 ),
1468 ),
1469 ),
1470 [WS_METHODS.serverReportHostPowerState]: (input) =>
1471 observeRpcEffect(
1472 WS_METHODS.serverReportHostPowerState,
1473 backgroundPolicy.reportHostPowerState(input),
1474 { "rpc.aggregate": "server" },
1475 ),
1476 [WS_METHODS.serverGetBackgroundPolicy]: (_input) =>
1477 observeRpcEffect(WS_METHODS.serverGetBackgroundPolicy, backgroundPolicy.snapshot, {
1478 "rpc.aggregate": "server",
1479 }),
15011480 [WS_METHODS.cloudGetRelayClientStatus]: (_input) =>
15021481 observeRpcEffect(WS_METHODS.cloudGetRelayClientStatus, relayClient.resolve, {
15031482 "rpc.aggregate": "cloud",
......@@ -2021,6 +2000,26 @@ const makeWsRpcLayer = (
20212000 }),
20222001 { "rpc.aggregate": "auth" },
20232002 ),
2003 [WS_METHODS.subscribeBackgroundPolicy]: (_input) =>
2004 observeRpcStream(
2005 WS_METHODS.subscribeBackgroundPolicy,
2006 Stream.unwrap(
2007 Effect.map(backgroundPolicy.subscribe, ({ latest, changes }) =>
2008 Stream.concat(Stream.make(latest), changes),
2009 ),
2010 ),
2011 { "rpc.aggregate": "server" },
2012 ),
2013 [WS_METHODS.subscribeResourceTelemetry]: (_input) =>
2014 observeRpcStream(
2015 WS_METHODS.subscribeResourceTelemetry,
2016 Stream.unwrap(
2017 Effect.map(resourceTelemetry.subscribe, ({ latest, changes }) =>
2018 Stream.concat(Stream.make(latest), changes),
2019 ),
2020 ),
2021 { "rpc.aggregate": "server" },
2022 ),
20242023 });
20252024 }),
20262025 );
apps/web/src/components/settings/DiagnosticsSettings.tsx+10-3
......@@ -37,6 +37,7 @@ import { Button } from "../ui/button";
3737import { ScrollArea } from "../ui/scroll-area";
3838import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
3939import { toastManager } from "../ui/toast";
40import { ResourceTelemetryDiagnostics } from "./ResourceTelemetryDiagnostics";
4041import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout";
4142import { useAtomCommand } from "../../state/use-atom-command";
4243
......@@ -904,12 +905,16 @@ export function DiagnosticsSettingsPanel() {
904905 if (environmentId === null) {
905906 return;
906907 }
908 const process = processData?.processes.find((entry) => entry.pid === pid);
909 if (process === undefined) {
910 return;
911 }
907912
908913 setSignalingPid(pid);
909914 void (async () => {
910915 const result = await signalServerProcess({
911916 environmentId,
912 input: { pid, signal },
917 input: { pid, startTimeMs: process.startTimeMs, signal },
913918 });
914919 setSignalingPid(null);
915920 if (result._tag === "Failure") {
......@@ -946,7 +951,7 @@ export function DiagnosticsSettingsPanel() {
946951 refreshProcesses();
947952 })();
948953 },
949 [environmentId, refreshProcesses, signalServerProcess],
954 [environmentId, processData?.processes, refreshProcesses, signalServerProcess],
950955 );
951956
952957 const processDiagnosticsError = processData ? Option.getOrNull(processData.error) : null;
......@@ -957,7 +962,9 @@ export function DiagnosticsSettingsPanel() {
957962 : false;
958963
959964 return (
960 <SettingsPageContainer>
965 <SettingsPageContainer className="max-w-6xl gap-10">
966 <ResourceTelemetryDiagnostics />
967
961968 <SettingsSection
962969 title="Live Processes"
963970 headerAction={
apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.test.ts created+90
......@@ -0,0 +1,90 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import type { ResourceTelemetryProcess } from "@t3tools/contracts";
4import * as DateTime from "effect/DateTime";
5
6import {
7 resourceHistoryBarHeight,
8 resourceHistoryCpuScaleMax,
9 shouldShowResourceMonitorRetry,
10 visibleResourceTelemetryProcesses,
11} from "./ResourceTelemetryDiagnostics.logic";
12
13const process = (pid: number, ppid: number, depth: number): ResourceTelemetryProcess => ({
14 identity: { pid, startTimeMs: pid * 1_000 },
15 ppid,
16 childPids: [],
17 depth,
18 name: `process-${pid}`,
19 command: `process-${pid}`,
20 status: "Running",
21 category: pid === 1 ? "server" : "server-child",
22 cpuPercent: 0,
23 cpuTimeMs: 0,
24 residentBytes: 0,
25 peakResidentBytes: 0,
26 virtualBytes: 0,
27 ioReadBytes: 0,
28 ioWriteBytes: 0,
29 ioReadBytesPerSecond: 0,
30 ioWriteBytesPerSecond: 0,
31 ioSemantics: "storage",
32 runTimeMs: 0,
33 firstSeenAt: DateTime.makeUnsafe(0),
34 lastSeenAt: DateTime.makeUnsafe(0),
35});
36
37describe("shouldShowResourceMonitorRetry", () => {
38 it("allows retry when the initial telemetry request fails before a snapshot", () => {
39 expect(
40 shouldShowResourceMonitorRetry({
41 nativeStatus: null,
42 error: "Resource monitor is unavailable.",
43 }),
44 ).toBe(true);
45 });
46
47 it("does not show retry for an initial load without an error or a healthy snapshot", () => {
48 expect(shouldShowResourceMonitorRetry({ nativeStatus: null, error: null })).toBe(false);
49 expect(shouldShowResourceMonitorRetry({ nativeStatus: "healthy", error: null })).toBe(false);
50 });
51});
52
53describe("resourceHistoryBarHeight", () => {
54 it("renders an exactly zero sample with no visible bar", () => {
55 expect(resourceHistoryBarHeight({ value: 0, max: 100, minimumVisiblePercent: 2 })).toBe(0);
56 });
57
58 it("keeps nonzero samples visible without changing proportional heights", () => {
59 expect(resourceHistoryBarHeight({ value: 0.5, max: 100, minimumVisiblePercent: 2 })).toBe(2);
60 expect(resourceHistoryBarHeight({ value: 50, max: 100, minimumVisiblePercent: 2 })).toBe(50);
61 });
62});
63
64describe("resourceHistoryCpuScaleMax", () => {
65 it("scales average bars independently of brief peak spikes", () => {
66 const buckets = [
67 { avgCpuPercent: 1, maxCpuPercent: 100 },
68 { avgCpuPercent: 8, maxCpuPercent: 8 },
69 ];
70 expect(resourceHistoryCpuScaleMax(buckets)).toBe(8);
71 });
72});
73
74describe("visibleResourceTelemetryProcesses", () => {
75 it("hides descendants by parentage even when rows are not depth-first", () => {
76 const processes = [
77 process(1, 0, 0),
78 process(10, 0, 0),
79 process(2, 1, 1),
80 process(11, 10, 1),
81 process(3, 2, 2),
82 ];
83
84 expect(visibleResourceTelemetryProcesses(processes, new Set(["1:1000"]))).toEqual([
85 processes[0],
86 processes[1],
87 processes[3],
88 ]);
89 });
90});
apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.ts created+60
......@@ -0,0 +1,60 @@
1import type { ResourceTelemetryProcess, ResourceTelemetrySourceStatus } from "@t3tools/contracts";
2
3function processIdentityKey(process: ResourceTelemetryProcess): string {
4 return `${process.identity.pid}:${process.identity.startTimeMs}`;
5}
6
7export function visibleResourceTelemetryProcesses(
8 processes: ReadonlyArray<ResourceTelemetryProcess>,
9 collapsed: ReadonlySet<string>,
10): ReadonlyArray<ResourceTelemetryProcess> {
11 const childrenByParent = new Map<number, ResourceTelemetryProcess[]>();
12 for (const process of processes) {
13 const children = childrenByParent.get(process.ppid) ?? [];
14 children.push(process);
15 childrenByParent.set(process.ppid, children);
16 }
17
18 const hidden = new Set<string>();
19 const hideDescendants = (pid: number): void => {
20 for (const child of childrenByParent.get(pid) ?? []) {
21 const key = processIdentityKey(child);
22 if (hidden.has(key)) continue;
23 hidden.add(key);
24 hideDescendants(child.identity.pid);
25 }
26 };
27 for (const process of processes) {
28 if (collapsed.has(processIdentityKey(process))) {
29 hideDescendants(process.identity.pid);
30 }
31 }
32 return processes.filter((process) => !hidden.has(processIdentityKey(process)));
33}
34
35export function shouldShowResourceMonitorRetry(input: {
36 readonly nativeStatus: ResourceTelemetrySourceStatus | null;
37 readonly error: string | null;
38}): boolean {
39 return (
40 (input.nativeStatus === null && input.error !== null) ||
41 input.nativeStatus === "degraded" ||
42 input.nativeStatus === "unavailable" ||
43 input.nativeStatus === "stopped"
44 );
45}
46
47export function resourceHistoryBarHeight(input: {
48 readonly value: number;
49 readonly max: number;
50 readonly minimumVisiblePercent: number;
51}): number {
52 if (input.value <= 0) return 0;
53 return Math.max(input.minimumVisiblePercent, (input.value / Math.max(1, input.max)) * 100);
54}
55
56export function resourceHistoryCpuScaleMax(
57 buckets: ReadonlyArray<{ readonly avgCpuPercent: number }>,
58): number {
59 return Math.max(1, ...buckets.map((bucket) => bucket.avgCpuPercent));
60}
apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx created+1268
......@@ -0,0 +1,1268 @@
1import {
2 ActivityIcon,
3 AlertTriangleIcon,
4 BatteryIcon,
5 ChevronDownIcon,
6 ChevronRightIcon,
7 CpuIcon,
8 DatabaseIcon,
9 GaugeIcon,
10 HardDriveIcon,
11 MemoryStickIcon,
12 RefreshCwIcon,
13 RotateCcwIcon,
14} from "lucide-react";
15import type {
16 BackgroundBooleanState,
17 ResourceAttributionEntry,
18 ResourceTelemetryAggregate,
19 ResourceTelemetryHistoryBucket,
20 ResourceTelemetryIoSemantics,
21 ResourceTelemetryProcess,
22 ResourceTelemetryProcessCategory,
23 ResourceTelemetryProcessSummary,
24 ResourceTelemetrySourceHealth,
25 ResourceTelemetrySourceStatus,
26 ServerProcessSignal,
27} from "@t3tools/contracts";
28import * as DateTime from "effect/DateTime";
29import * as Option from "effect/Option";
30import { useCallback, useMemo, useState, type ReactNode } from "react";
31import {
32 isAtomCommandInterrupted,
33 squashAtomCommandFailure,
34} from "@t3tools/client-runtime/state/runtime";
35
36import {
37 useResourceTelemetry,
38 useResourceTelemetryHistory,
39} from "../../lib/resourceTelemetryState";
40import { cn } from "../../lib/utils";
41import { usePrimaryEnvironment } from "../../state/environments";
42import { serverEnvironment } from "../../state/server";
43import { useAtomCommand } from "../../state/use-atom-command";
44import { formatRelativeTime } from "../../timestampFormat";
45import { Button } from "../ui/button";
46import { ScrollArea } from "../ui/scroll-area";
47import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
48import { toastManager } from "../ui/toast";
49import {
50 resourceHistoryBarHeight,
51 resourceHistoryCpuScaleMax,
52 shouldShowResourceMonitorRetry,
53 visibleResourceTelemetryProcesses,
54} from "./ResourceTelemetryDiagnostics.logic";
55import { SettingsSection, useRelativeTimeTick } from "./settingsLayout";
56
57const HISTORY_WINDOWS = [
58 { label: "5m", windowMs: 5 * 60_000, bucketMs: 15_000 },
59 { label: "15m", windowMs: 15 * 60_000, bucketMs: 30_000 },
60 { label: "30m", windowMs: 30 * 60_000, bucketMs: 60_000 },
61 { label: "1h", windowMs: 60 * 60_000, bucketMs: 2 * 60_000 },
62] as const;
63
64function formatBytes(value: number): string {
65 if (value < 1_024) return `${Math.round(value)} B`;
66 const units = ["KB", "MB", "GB", "TB"] as const;
67 let next = value;
68 let unitIndex = -1;
69 do {
70 next /= 1_024;
71 unitIndex += 1;
72 } while (next >= 1_024 && unitIndex < units.length - 1);
73 return `${next.toFixed(next >= 100 ? 0 : next >= 10 ? 1 : 2)} ${units[unitIndex]}`;
74}
75
76function formatRate(value: number): string {
77 return `${formatBytes(value)}/s`;
78}
79
80function formatCpuTime(valueMs: number): string {
81 const seconds = valueMs / 1_000;
82 if (seconds < 60) return `${seconds.toFixed(seconds >= 10 ? 1 : 2)}s`;
83 const minutes = seconds / 60;
84 if (minutes < 60) return `${minutes.toFixed(minutes >= 10 ? 1 : 2)}m`;
85 return `${(minutes / 60).toFixed(2)}h`;
86}
87
88function formatDurationMicros(value: number): string {
89 if (value < 1_000) return `${Math.round(value)} µs`;
90 if (value < 1_000_000) return `${(value / 1_000).toFixed(2)} ms`;
91 return `${(value / 1_000_000).toFixed(2)} s`;
92}
93
94function formatSampleInterval(valueMs: number): string {
95 if (valueMs < 1_000) return `${Math.max(0, Math.round(valueMs))} ms`;
96 const seconds = valueMs / 1_000;
97 return `${seconds.toLocaleString(undefined, { maximumFractionDigits: 1 })} ${
98 seconds === 1 ? "second" : "seconds"
99 }`;
100}
101
102function processIdentityKey(process: ResourceTelemetryProcess): string {
103 return `${process.identity.pid}:${process.identity.startTimeMs}`;
104}
105
106function processSummaryIdentityKey(process: ResourceTelemetryProcessSummary): string {
107 return `${process.identity.pid}:${process.identity.startTimeMs}`;
108}
109
110function formatProcessName(process: Pick<ResourceTelemetryProcess, "command" | "name">): string {
111 if (process.name.trim()) return process.name;
112 const firstToken = process.command.trim().split(/\s+/)[0] ?? process.command;
113 const normalized = firstToken.replace(/^['"]|['"]$/g, "");
114 return normalized.split(/[\\/]/).findLast((segment) => segment.length > 0) ?? normalized;
115}
116
117function categoryLabel(category: ResourceTelemetryProcessCategory): string {
118 switch (category) {
119 case "server":
120 return "Server";
121 case "server-child":
122 return "Backend child";
123 case "provider-root":
124 return "Provider";
125 case "terminal-root":
126 return "Terminal";
127 case "electron-main":
128 return "Electron main";
129 case "electron-renderer":
130 return "Renderer";
131 case "electron-gpu":
132 return "GPU";
133 case "electron-utility":
134 return "Electron utility";
135 case "resource-monitor":
136 return "Monitor";
137 case "unknown-t3":
138 return "T3 process";
139 }
140}
141
142function categoryDotClass(category: ResourceTelemetryProcessCategory): string {
143 if (category === "resource-monitor") return "bg-amber-500";
144 if (category.startsWith("electron-")) return "bg-sky-500";
145 if (category === "server") return "bg-violet-500";
146 return "bg-emerald-500";
147}
148
149function ioSemanticsLabel(semantics: ResourceTelemetryIoSemantics): string {
150 switch (semantics) {
151 case "storage":
152 return "Storage bytes";
153 case "logical":
154 return "Logical bytes";
155 case "all-io":
156 return "All I/O bytes";
157 case "unavailable":
158 return "Unavailable";
159 }
160}
161
162function booleanStateLabel(
163 value: BackgroundBooleanState,
164 labels: { readonly true: string; readonly false: string },
165): string {
166 if (value === "true") return labels.true;
167 if (value === "false") return labels.false;
168 return "Unknown";
169}
170
171function sourceStatusTone(status: ResourceTelemetrySourceStatus): "default" | "warning" | "danger" {
172 if (status === "healthy") return "default";
173 if (status === "starting" || status === "degraded") return "warning";
174 return "danger";
175}
176
177function SourceStatusBadge({
178 label,
179 status,
180 presentation,
181}: {
182 label: string;
183 status: ResourceTelemetrySourceStatus;
184 presentation?:
185 | {
186 readonly label: string;
187 readonly tone: "neutral";
188 }
189 | undefined;
190}) {
191 const tone = presentation?.tone ?? sourceStatusTone(status);
192 return (
193 <span
194 className={cn(
195 "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.08em]",
196 tone === "neutral" && "border-border/70 bg-muted/45 text-muted-foreground",
197 tone === "default" &&
198 "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
199 tone === "warning" &&
200 "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
201 tone === "danger" && "border-destructive/30 bg-destructive/10 text-destructive",
202 )}
203 >
204 <span
205 className={cn(
206 "size-1.5 rounded-full",
207 tone === "neutral" && "bg-muted-foreground/55",
208 tone === "default" && "bg-emerald-500",
209 tone === "warning" && "bg-amber-500",
210 tone === "danger" && "bg-destructive",
211 )}
212 />
213 {label} {presentation?.label ?? status}
214 </span>
215 );
216}
217
218function LastSampleLabel({ sampledAt }: { sampledAt: DateTime.Utc | null }) {
219 useRelativeTimeTick();
220 if (!sampledAt) {
221 return <span className="text-[11px] text-muted-foreground/55">Waiting for sample</span>;
222 }
223 const relative = formatRelativeTime(DateTime.formatIso(sampledAt));
224 if (!relative) {
225 return <span className="text-[11px] text-muted-foreground/55">Waiting for sample</span>;
226 }
227 return (
228 <span className="text-[11px] text-muted-foreground/60">
229 Updated <span className="font-mono tabular-nums">{relative.value}</span>
230 {relative.suffix ? ` ${relative.suffix}` : ""}
231 </span>
232 );
233}
234
235function IconStat({
236 icon,
237 label,
238 value,
239 detail,
240 tone = "default",
241}: {
242 icon: ReactNode;
243 label: string;
244 value: string;
245 detail?: string | undefined;
246 tone?: "default" | "warning" | "danger";
247}) {
248 return (
249 <div className="group min-w-0 px-4 py-4 sm:px-5">
250 <div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.11em] text-muted-foreground/70">
251 <span className="text-muted-foreground/55 transition-colors group-hover:text-foreground/65">
252 {icon}
253 </span>
254 <span className="truncate">{label}</span>
255 </div>
256 <div
257 className={cn(
258 "mt-2.5 truncate font-mono text-2xl font-semibold tracking-[-0.05em] tabular-nums text-foreground",
259 tone === "warning" && "text-amber-600 dark:text-amber-300",
260 tone === "danger" && "text-destructive",
261 )}
262 >
263 {value}
264 </div>
265 {detail ? (
266 <div className="mt-1.5 truncate text-[10px] text-muted-foreground/60">{detail}</div>
267 ) : null}
268 </div>
269 );
270}
271
272function AggregateCard({
273 label,
274 accentClass,
275 aggregate,
276}: {
277 label: string;
278 accentClass: string;
279 aggregate: ResourceTelemetryAggregate;
280}) {
281 return (
282 <div className="relative overflow-hidden border-t border-border/60 px-4 py-4 first:border-t-0 md:border-t-0 md:border-l md:first:border-l-0 sm:px-5">
283 <span className={cn("absolute inset-x-5 top-0 h-0.5 rounded-full opacity-75", accentClass)} />
284 <div className="flex items-center justify-between gap-3">
285 <div className="text-[10px] font-semibold uppercase tracking-[0.11em] text-muted-foreground/75">
286 {label}
287 </div>
288 <div className="rounded-md bg-muted/55 px-1.5 py-0.5 font-mono text-[9px] tabular-nums text-muted-foreground/70">
289 {aggregate.processCount} {aggregate.processCount === 1 ? "process" : "processes"}
290 </div>
291 </div>
292 <div className="mt-3.5 grid grid-cols-2 gap-x-4 gap-y-2.5">
293 <MetricPair label="CPU" value={`${aggregate.currentCpuPercent.toFixed(1)}%`} />
294 <MetricPair label="Memory" value={formatBytes(aggregate.currentRssBytes)} />
295 <MetricPair label="Read" value={formatRate(aggregate.ioReadBytesPerSecond)} />
296 <MetricPair label="Write" value={formatRate(aggregate.ioWriteBytesPerSecond)} />
297 </div>
298 </div>
299 );
300}
301
302function MetricPair({ label, value }: { label: string; value: string }) {
303 return (
304 <div className="min-w-0">
305 <div className="text-[9px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/45">
306 {label}
307 </div>
308 <div className="truncate font-mono text-xs font-medium tabular-nums text-foreground/90">
309 {value}
310 </div>
311 </div>
312 );
313}
314
315function HealthSource({ label, health }: { label: string; health: ResourceTelemetrySourceHealth }) {
316 const expectedInBrowser =
317 health.status === "unavailable" &&
318 Option.exists(health.lastError, (error) => error.includes("'web' mode"));
319 return (
320 <div className="flex items-start justify-between gap-4 border-t border-border/50 py-3 first:border-t-0">
321 <div className="min-w-0">
322 <div className="text-[13px] font-medium text-foreground">{label}</div>
323 <div className="mt-1 text-[11px] leading-relaxed text-muted-foreground/65">
324 {expectedInBrowser
325 ? "Available when this page runs inside the desktop app."
326 : Option.match(health.lastError, {
327 onNone: () => "No reported errors",
328 onSome: (error) => error,
329 })}
330 </div>
331 </div>
332 <SourceStatusBadge
333 label=""
334 status={health.status}
335 presentation={
336 expectedInBrowser
337 ? {
338 label: "Desktop only",
339 tone: "neutral",
340 }
341 : undefined
342 }
343 />
344 </div>
345 );
346}
347
348function DetailRow({
349 label,
350 value,
351 valueClassName,
352}: {
353 label: string;
354 value: ReactNode;
355 valueClassName?: string | undefined;
356}) {
357 return (
358 <div className="flex items-center justify-between gap-4 border-t border-border/50 py-2.5 first:border-t-0">
359 <span className="text-[11px] text-muted-foreground/75">{label}</span>
360 <span
361 className={cn(
362 "min-w-0 truncate text-right font-mono text-[11px] tabular-nums text-foreground/85",
363 valueClassName,
364 )}
365 >
366 {value}
367 </span>
368 </div>
369 );
370}
371
372function HistoryWindowSelector({
373 selectedWindowMs,
374 onSelect,
375}: {
376 selectedWindowMs: number;
377 onSelect: (windowMs: number) => void;
378}) {
379 return (
380 <div className="flex items-center rounded-md border border-border/60 p-0.5">
381 {HISTORY_WINDOWS.map((option) => (
382 <button
383 key={option.windowMs}
384 type="button"
385 className={cn(
386 "h-6 rounded-sm px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground",
387 selectedWindowMs === option.windowMs && "bg-muted text-foreground",
388 )}
389 onClick={() => onSelect(option.windowMs)}
390 >
391 {option.label}
392 </button>
393 ))}
394 </div>
395 );
396}
397
398function ResourceHistoryChart({
399 buckets,
400}: {
401 buckets: ReadonlyArray<ResourceTelemetryHistoryBucket>;
402}) {
403 const maxCpu = resourceHistoryCpuScaleMax(buckets);
404 const maxIo = Math.max(1, ...buckets.map((bucket) => bucket.ioReadBytes + bucket.ioWriteBytes));
405
406 return (
407 <div className="border-t border-border/60 px-4 py-4 sm:px-5">
408 <div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[10px] text-muted-foreground/65">
409 <span className="inline-flex items-center gap-1.5">
410 <span className="h-1.5 w-3 rounded-full bg-foreground/70" /> CPU average
411 </span>
412 <span className="inline-flex items-center gap-1.5">
413 <span className="h-1.5 w-3 rounded-full bg-sky-500/70" /> I/O reads
414 </span>
415 <span className="inline-flex items-center gap-1.5">
416 <span className="h-1.5 w-3 rounded-full bg-amber-500/80" /> I/O writes
417 </span>
418 </div>
419 <div className="flex h-32 items-end gap-1 overflow-hidden rounded-lg border border-border/40 bg-muted/8 px-2 pt-3 pb-2">
420 {buckets.map((bucket) => {
421 const cpuHeight = resourceHistoryBarHeight({
422 value: bucket.avgCpuPercent,
423 max: maxCpu,
424 minimumVisiblePercent: 2,
425 });
426 const readHeight = resourceHistoryBarHeight({
427 value: bucket.ioReadBytes,
428 max: maxIo,
429 minimumVisiblePercent: 1,
430 });
431 const writeHeight = resourceHistoryBarHeight({
432 value: bucket.ioWriteBytes,
433 max: maxIo,
434 minimumVisiblePercent: 1,
435 });
436 return (
437 <Tooltip key={DateTime.formatIso(bucket.startedAt)}>
438 <TooltipTrigger
439 render={
440 <div className="grid h-full min-w-1 flex-1 grid-cols-3 items-end gap-px">
441 <span
442 className="block rounded-t-sm bg-foreground/65"
443 style={{ height: `${cpuHeight}%` }}
444 />
445 <span
446 className="block rounded-t-sm bg-sky-500/70"
447 style={{ height: `${readHeight}%` }}
448 />
449 <span
450 className="block rounded-t-sm bg-amber-500/80"
451 style={{ height: `${writeHeight}%` }}
452 />
453 </div>
454 }
455 />
456 <TooltipPopup side="top" className="space-y-0.5 text-left">
457 <div>CPU avg {bucket.avgCpuPercent.toFixed(1)}%</div>
458 <div>CPU peak {bucket.maxCpuPercent.toFixed(1)}%</div>
459 <div>Read {formatBytes(bucket.ioReadBytes)}</div>
460 <div>Write {formatBytes(bucket.ioWriteBytes)}</div>
461 </TooltipPopup>
462 </Tooltip>
463 );
464 })}
465 </div>
466 </div>
467 );
468}
469
470function ProcessTreeName({
471 process,
472 collapsed,
473 onToggle,
474}: {
475 process: ResourceTelemetryProcess;
476 collapsed: boolean;
477 onToggle: (process: ResourceTelemetryProcess) => void;
478}) {
479 const name = formatProcessName(process);
480 const hasChildren = process.childPids.length > 0;
481 const ChevronIcon = collapsed ? ChevronRightIcon : ChevronDownIcon;
482 return (
483 <div
484 className="grid min-w-0 grid-cols-[1.25rem_0.375rem_minmax(0,1fr)] items-center gap-2"
485 style={{ paddingLeft: `${Math.min(process.depth, 7) * 10}px` }}
486 >
487 {hasChildren ? (
488 <button
489 type="button"
490 className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
491 onClick={() => onToggle(process)}
492 aria-label={collapsed ? `Expand ${name}` : `Collapse ${name}`}
493 >
494 <ChevronIcon className="size-3.5" />
495 </button>
496 ) : (
497 <span className="size-5" aria-hidden />
498 )}
499 <span className={cn("size-1.5 rounded-full", categoryDotClass(process.category))} />
500 <Tooltip>
501 <TooltipTrigger
502 render={<span className="min-w-0 truncate font-medium text-foreground">{name}</span>}
503 />
504 <TooltipPopup
505 side="top"
506 className="max-w-[min(520px,calc(100vw-2rem))] whitespace-normal break-words text-left font-mono text-[11px]"
507 >
508 {process.command || process.name}
509 </TooltipPopup>
510 </Tooltip>
511 </div>
512 );
513}
514
515function canSignalProcess(process: ResourceTelemetryProcess): boolean {
516 return (
517 process.category === "server-child" ||
518 process.category === "provider-root" ||
519 process.category === "terminal-root"
520 );
521}
522
523function ProcessActions({
524 process,
525 signalingKeys,
526 onSignal,
527}: {
528 process: ResourceTelemetryProcess;
529 signalingKeys: ReadonlySet<string>;
530 onSignal: (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => void;
531}) {
532 if (!canSignalProcess(process)) {
533 return <span className="text-[10px] text-muted-foreground/35">—</span>;
534 }
535 const isSignaling = signalingKeys.has(processIdentityKey(process));
536 return (
537 <div className="flex items-center justify-end gap-1.5">
538 <button
539 type="button"
540 disabled={isSignaling}
541 className="text-[10px] font-semibold text-muted-foreground hover:text-foreground disabled:opacity-50"
542 onClick={() => onSignal(process, "SIGINT")}
543 >
544 INT
545 </button>
546 <button
547 type="button"
548 disabled={isSignaling}
549 className="text-[10px] font-semibold text-destructive hover:underline disabled:opacity-50"
550 onClick={() => onSignal(process, "SIGKILL")}
551 >
552 KILL
553 </button>
554 </div>
555 );
556}
557
558function ProcessTable({
559 processes,
560 signalingKeys,
561 onSignal,
562}: {
563 processes: ReadonlyArray<ResourceTelemetryProcess>;
564 signalingKeys: ReadonlySet<string>;
565 onSignal: (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => void;
566}) {
567 const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set());
568 const visible = useMemo(
569 () => visibleResourceTelemetryProcesses(processes, collapsed),
570 [collapsed, processes],
571 );
572 const toggle = useCallback((process: ResourceTelemetryProcess) => {
573 const identityKey = processIdentityKey(process);
574 setCollapsed((current) => {
575 const next = new Set(current);
576 if (next.has(identityKey)) {
577 next.delete(identityKey);
578 } else {
579 next.add(identityKey);
580 }
581 return next;
582 });
583 }, []);
584
585 return (
586 <ScrollArea
587 chainVerticalScroll
588 scrollFade
589 hideScrollbars
590 className="max-h-[min(68vh,48rem)] w-full max-w-full border-t border-border/60"
591 >
592 <table className="w-full min-w-[1320px] table-fixed text-left text-xs">
593 <colgroup>
594 <col className="w-[20%]" />
595 <col className="w-[10%]" />
596 <col className="w-[7%]" />
597 <col className="w-[8%]" />
598 <col className="w-[9%]" />
599 <col className="w-[9%]" />
600 <col className="w-[9%]" />
601 <col className="w-[10%]" />
602 <col className="w-[8%]" />
603 <col className="w-[6%]" />
604 <col className="w-[4%]" />
605 </colgroup>
606 <thead className="sticky top-0 z-10 border-b border-border/60 bg-card text-[10px] uppercase tracking-[0.08em] text-muted-foreground/65">
607 <tr>
608 <th className="px-4 py-2 font-semibold sm:pl-5">Process</th>
609 <th className="px-3 py-2 font-semibold">Category</th>
610 <th className="px-3 py-2 text-right font-semibold">CPU</th>
611 <th className="px-3 py-2 text-right font-semibold">CPU Time</th>
612 <th className="px-3 py-2 text-right font-semibold">Memory</th>
613 <th className="px-3 py-2 text-right font-semibold">Read/s</th>
614 <th className="px-3 py-2 text-right font-semibold">Write/s</th>
615 <th className="px-3 py-2 text-right font-semibold">Read Total</th>
616 <th className="px-3 py-2 text-right font-semibold">Write Total</th>
617 <th className="px-3 py-2 text-right font-semibold">PID</th>
618 <th className="px-2 py-2 text-right font-semibold sm:pr-4">Kill</th>
619 </tr>
620 </thead>
621 <tbody className="divide-y divide-border/50">
622 {visible.length === 0 ? (
623 <tr>
624 <td colSpan={11} className="px-4 py-5 text-xs text-muted-foreground sm:px-5">
625 Waiting for the native process monitor.
626 </td>
627 </tr>
628 ) : null}
629 {visible.map((process) => (
630 <tr key={processIdentityKey(process)} className="hover:bg-muted/20">
631 <td className="px-4 py-2 sm:pl-5">
632 <ProcessTreeName
633 process={process}
634 collapsed={collapsed.has(processIdentityKey(process))}
635 onToggle={toggle}
636 />
637 </td>
638 <td className="truncate px-3 py-2 text-[11px] text-muted-foreground">
639 {categoryLabel(process.category)}
640 </td>
641 <td className="px-3 py-2 text-right font-mono tabular-nums">
642 {process.cpuPercent.toFixed(1)}%
643 </td>
644 <td className="px-3 py-2 text-right font-mono tabular-nums">
645 {formatCpuTime(process.cpuTimeMs)}
646 </td>
647 <td className="px-3 py-2 text-right font-mono tabular-nums">
648 {formatBytes(process.residentBytes)}
649 </td>
650 <td className="px-3 py-2 text-right font-mono tabular-nums text-sky-700 dark:text-sky-300">
651 {formatRate(process.ioReadBytesPerSecond)}
652 </td>
653 <td className="px-3 py-2 text-right font-mono tabular-nums text-amber-700 dark:text-amber-300">
654 {formatRate(process.ioWriteBytesPerSecond)}
655 </td>
656 <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground">
657 {formatBytes(process.ioReadBytes)}
658 </td>
659 <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground">
660 <Tooltip>
661 <TooltipTrigger render={<span>{formatBytes(process.ioWriteBytes)}</span>} />
662 <TooltipPopup side="top">{ioSemanticsLabel(process.ioSemantics)}</TooltipPopup>
663 </Tooltip>
664 </td>
665 <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground">
666 {process.identity.pid}
667 </td>
668 <td className="px-2 py-2 text-right sm:pr-4">
669 <ProcessActions
670 process={process}
671 signalingKeys={signalingKeys}
672 onSignal={onSignal}
673 />
674 </td>
675 </tr>
676 ))}
677 </tbody>
678 </table>
679 </ScrollArea>
680 );
681}
682
683function HistoryProcessTable({
684 processes,
685}: {
686 processes: ReadonlyArray<ResourceTelemetryProcessSummary>;
687}) {
688 return (
689 <ScrollArea
690 chainVerticalScroll
691 scrollFade
692 hideScrollbars
693 className="max-h-[28rem] w-full max-w-full border-t border-border/60"
694 >
695 <table className="w-full min-w-[1020px] table-fixed text-left text-xs">
696 <colgroup>
697 <col className="w-[24%]" />
698 <col className="w-[11%]" />
699 <col className="w-[10%]" />
700 <col className="w-[10%]" />
701 <col className="w-[11%]" />
702 <col className="w-[11%]" />
703 <col className="w-[11%]" />
704 <col className="w-[7%]" />
705 <col className="w-[5%]" />
706 </colgroup>
707 <thead className="sticky top-0 z-10 border-b border-border/60 bg-card text-[10px] uppercase tracking-[0.08em] text-muted-foreground/65">
708 <tr>
709 <th className="px-4 py-2 font-semibold sm:pl-5">Process</th>
710 <th className="px-3 py-2 font-semibold">Category</th>
711 <th className="px-3 py-2 text-right font-semibold">CPU Time</th>
712 <th className="px-3 py-2 text-right font-semibold">Peak CPU</th>
713 <th className="px-3 py-2 text-right font-semibold">Peak Mem</th>
714 <th className="px-3 py-2 text-right font-semibold">Read</th>
715 <th className="px-3 py-2 text-right font-semibold">Write</th>
716 <th className="px-3 py-2 text-right font-semibold">Samples</th>
717 <th className="px-3 py-2 text-right font-semibold sm:pr-5">PID</th>
718 </tr>
719 </thead>
720 <tbody className="divide-y divide-border/50">
721 {processes.length === 0 ? (
722 <tr>
723 <td colSpan={9} className="px-4 py-5 text-xs text-muted-foreground sm:px-5">
724 No retained process samples in this window.
725 </td>
726 </tr>
727 ) : null}
728 {processes.map((process) => (
729 <tr key={processSummaryIdentityKey(process)} className="hover:bg-muted/20">
730 <td className="px-4 py-2 sm:pl-5">
731 <Tooltip>
732 <TooltipTrigger
733 render={
734 <span className="block truncate font-medium text-foreground">
735 {process.name || process.command}
736 </span>
737 }
738 />
739 <TooltipPopup
740 side="top"
741 className="max-w-[min(520px,calc(100vw-2rem))] whitespace-normal break-words text-left font-mono text-[11px]"
742 >
743 {process.command || process.name}
744 </TooltipPopup>
745 </Tooltip>
746 </td>
747 <td className="truncate px-3 py-2 text-[11px] text-muted-foreground">
748 {categoryLabel(process.category)}
749 </td>
750 <td className="px-3 py-2 text-right font-mono tabular-nums">
751 {formatCpuTime(process.cpuTimeMs)}
752 </td>
753 <td className="px-3 py-2 text-right font-mono tabular-nums">
754 {process.maxCpuPercent.toFixed(1)}%
755 </td>
756 <td className="px-3 py-2 text-right font-mono tabular-nums">
757 {formatBytes(process.peakRssBytes)}
758 </td>
759 <td className="px-3 py-2 text-right font-mono tabular-nums text-sky-700 dark:text-sky-300">
760 {formatBytes(process.ioReadBytes)}
761 </td>
762 <td className="px-3 py-2 text-right font-mono tabular-nums text-amber-700 dark:text-amber-300">
763 {formatBytes(process.ioWriteBytes)}
764 </td>
765 <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground">
766 {process.sampleCount}
767 </td>
768 <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground sm:pr-5">
769 {process.identity.pid}
770 </td>
771 </tr>
772 ))}
773 </tbody>
774 </table>
775 </ScrollArea>
776 );
777}
778
779function AttributionTable({ entries }: { entries: ReadonlyArray<ResourceAttributionEntry> }) {
780 return (
781 <div className="overflow-x-auto border-t border-border/60">
782 <table className="w-full min-w-[720px] table-fixed text-left text-xs">
783 <colgroup>
784 <col className="w-[22%]" />
785 <col className="w-[28%]" />
786 <col className="w-[14%]" />
787 <col className="w-[14%]" />
788 <col className="w-[10%]" />
789 <col className="w-[12%]" />
790 </colgroup>
791 <thead className="border-b border-border/60 text-[10px] uppercase tracking-[0.08em] text-muted-foreground/65">
792 <tr>
793 <th className="px-4 py-2 font-semibold sm:pl-5">Component</th>
794 <th className="px-3 py-2 font-semibold">Operation</th>
795 <th className="px-3 py-2 text-right font-semibold">Logical Read</th>
796 <th className="px-3 py-2 text-right font-semibold">Logical Write</th>
797 <th className="px-3 py-2 text-right font-semibold">Count</th>
798 <th className="px-3 py-2 text-right font-semibold sm:pr-5">Time</th>
799 </tr>
800 </thead>
801 <tbody className="divide-y divide-border/50">
802 {entries.length === 0 ? (
803 <tr>
804 <td colSpan={6} className="px-4 py-5 text-xs text-muted-foreground sm:px-5">
805 No instrumented application I/O has been recorded yet.
806 </td>
807 </tr>
808 ) : null}
809 {entries.map((entry) => (
810 <tr key={`${entry.component}:${entry.operation}`} className="hover:bg-muted/20">
811 <td className="truncate px-4 py-2 font-medium text-foreground sm:pl-5">
812 {entry.component}
813 </td>
814 <td className="truncate px-3 py-2 text-muted-foreground">{entry.operation}</td>
815 <td className="px-3 py-2 text-right font-mono tabular-nums text-sky-700 dark:text-sky-300">
816 {formatBytes(entry.logicalReadBytes)}
817 </td>
818 <td className="px-3 py-2 text-right font-mono tabular-nums text-amber-700 dark:text-amber-300">
819 {formatBytes(entry.logicalWriteBytes)}
820 </td>
821 <td className="px-3 py-2 text-right font-mono tabular-nums">{entry.count}</td>
822 <td className="px-3 py-2 text-right font-mono tabular-nums text-muted-foreground sm:pr-5">
823 {(entry.durationMs / 1_000).toFixed(2)}s
824 </td>
825 </tr>
826 ))}
827 </tbody>
828 </table>
829 </div>
830 );
831}
832
833export function ResourceTelemetryDiagnostics() {
834 const [windowMs, setWindowMs] = useState(15 * 60_000);
835 const selectedWindow =
836 HISTORY_WINDOWS.find((option) => option.windowMs === windowMs) ?? HISTORY_WINDOWS[1];
837 const telemetry = useResourceTelemetry();
838 const retryTelemetry = telemetry.retry;
839 const history = useResourceTelemetryHistory({
840 windowMs: selectedWindow.windowMs,
841 bucketMs: selectedWindow.bucketMs,
842 });
843 const primaryEnvironment = usePrimaryEnvironment();
844 const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, {
845 reportFailure: false,
846 });
847 const [signalingKeys, setSignalingKeys] = useState<ReadonlySet<string>>(() => new Set());
848 const [isRetrying, setIsRetrying] = useState(false);
849 const snapshot = telemetry.data;
850 const allT3 = snapshot?.groups.allT3;
851
852 const signalProcess = useCallback(
853 (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => {
854 if (
855 signal === "SIGKILL" &&
856 !window.confirm(
857 `Send SIGKILL to process ${process.identity.pid}? This cannot be handled by the process.`,
858 )
859 ) {
860 return;
861 }
862 const identityKey = processIdentityKey(process);
863 const environmentId = primaryEnvironment?.environmentId;
864 if (environmentId === undefined) {
865 return;
866 }
867 setSignalingKeys((current) => new Set(current).add(identityKey));
868 void signalServerProcess({
869 environmentId,
870 input: {
871 pid: process.identity.pid,
872 startTimeMs: process.identity.startTimeMs,
873 signal,
874 },
875 })
876 .then((result) => {
877 if (result._tag === "Failure") {
878 if (isAtomCommandInterrupted(result)) return;
879 throw squashAtomCommandFailure(result);
880 }
881 if (result.value.signaled) return;
882 toastManager.add({
883 type: "error",
884 title: `Could not send ${signal}`,
885 description: Option.getOrElse(
886 result.value.message,
887 () => `Failed to send ${signal} to process ${process.identity.pid}.`,
888 ),
889 });
890 })
891 .catch((error: unknown) => {
892 toastManager.add({
893 type: "error",
894 title: `Could not send ${signal}`,
895 description: error instanceof Error ? error.message : `Failed to send ${signal}.`,
896 });
897 })
898 .finally(() => {
899 setSignalingKeys((current) => {
900 if (!current.has(identityKey)) return current;
901 const next = new Set(current);
902 next.delete(identityKey);
903 return next;
904 });
905 });
906 },
907 [primaryEnvironment?.environmentId, signalServerProcess],
908 );
909
910 const retryCollector = useCallback(() => {
911 setIsRetrying(true);
912 void retryTelemetry()
913 .catch((error: unknown) => {
914 toastManager.add({
915 type: "error",
916 title: "Could not restart resource monitor",
917 description:
918 error instanceof Error ? error.message : "The resource monitor retry failed.",
919 });
920 })
921 .finally(() => {
922 setIsRetrying(false);
923 });
924 }, [retryTelemetry]);
925
926 const speedLimit = snapshot ? Option.getOrNull(snapshot.speedLimitPercent) : null;
927 const collectorNeedsRetry = shouldShowResourceMonitorRetry({
928 nativeStatus: snapshot?.health.native.status ?? null,
929 error: telemetry.error,
930 });
931 const hasHostPowerSignal =
932 snapshot !== null &&
933 (snapshot.power.onBattery !== "unknown" ||
934 snapshot.power.lowPowerMode !== "unknown" ||
935 snapshot.power.idle !== "unknown" ||
936 snapshot.power.locked !== "unknown" ||
937 snapshot.power.thermalState !== "unknown");
938
939 return (
940 <>
941 <SettingsSection
942 title="Resource monitor"
943 icon={<ActivityIcon className="size-4 text-muted-foreground" />}
944 headerAction={
945 <div className="flex items-center gap-2">
946 {snapshot ? (
947 <SourceStatusBadge label="Native" status={snapshot.health.native.status} />
948 ) : null}
949 <LastSampleLabel sampledAt={snapshot?.readAt ?? null} />
950 <Tooltip>
951 <TooltipTrigger
952 render={
953 <Button
954 size="icon-xs"
955 variant="ghost"
956 className="size-5 rounded-sm p-0"
957 disabled={telemetry.isPending}
958 onClick={telemetry.refresh}
959 aria-label="Refresh resource telemetry"
960 >
961 <RefreshCwIcon
962 className={cn("size-3", telemetry.isPending && "animate-spin")}
963 />
964 </Button>
965 }
966 />
967 <TooltipPopup side="top">Refresh telemetry snapshot</TooltipPopup>
968 </Tooltip>
969 </div>
970 }
971 >
972 <div className="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-[0_1px_1px_rgb(0_0_0/0.03),0_8px_30px_rgb(0_0_0/0.035)]">
973 <div className="flex flex-col gap-3 border-b border-border/60 bg-linear-to-r from-muted/45 via-muted/20 to-transparent px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
974 <div>
975 <div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/70">
976 T3 system footprint
977 </div>
978 <p className="mt-1 max-w-xl text-xs leading-relaxed text-muted-foreground">
979 Live native counters for the server, providers, terminals, desktop processes, and
980 the monitor itself.
981 </p>
982 </div>
983 <div className="flex items-center gap-2 text-[10px] text-muted-foreground/65">
984 <span className="size-1.5 rounded-full bg-emerald-500" />
985 Sampling every {snapshot ? formatSampleInterval(snapshot.sampleIntervalMs) : "..."}
986 </div>
987 </div>
988 <div className="grid grid-cols-2 divide-x divide-y divide-border/55 md:grid-cols-3">
989 <IconStat
990 icon={<CpuIcon className="size-3.5" />}
991 label="Current CPU"
992 value={allT3 ? `${allT3.currentCpuPercent.toFixed(1)}%` : "..."}
993 detail={allT3 ? `${formatCpuTime(allT3.cpuTimeMs)} observed CPU time` : undefined}
994 />
995 <IconStat
996 icon={<MemoryStickIcon className="size-3.5" />}
997 label="Resident memory"
998 value={allT3 ? formatBytes(allT3.currentRssBytes) : "..."}
999 detail={
1000 allT3 ? `${formatBytes(allT3.peakRssBytes)} combined process peaks` : undefined
1001 }
1002 />
1003 <IconStat
1004 icon={<ActivityIcon className="size-3.5" />}
1005 label="Process count"
1006 value={allT3 ? String(allT3.processCount) : "..."}
1007 detail={
1008 allT3 ? `${allT3.processStarts} starts · ${allT3.processExits} exits` : undefined
1009 }
1010 />
1011 <IconStat
1012 icon={<HardDriveIcon className="size-3.5" />}
1013 label="Read throughput"
1014 value={allT3 ? formatRate(allT3.ioReadBytesPerSecond) : "..."}
1015 detail={allT3 ? `${formatBytes(allT3.ioReadBytes)} observed` : undefined}
1016 />
1017 <IconStat
1018 icon={<DatabaseIcon className="size-3.5" />}
1019 label="Write throughput"
1020 value={allT3 ? formatRate(allT3.ioWriteBytesPerSecond) : "..."}
1021 detail={allT3 ? `${formatBytes(allT3.ioWriteBytes)} observed` : undefined}
1022 tone={
1023 allT3 && allT3.ioWriteBytesPerSecond >= 10 * 1_024 * 1_024
1024 ? "danger"
1025 : allT3 && allT3.ioWriteBytesPerSecond >= 1_024 * 1_024
1026 ? "warning"
1027 : "default"
1028 }
1029 />
1030 <IconStat
1031 icon={<GaugeIcon className="size-3.5" />}
1032 label="CPU speed limit"
1033 value={
1034 snapshot ? (speedLimit === null ? "Unknown" : `${speedLimit.toFixed(0)}%`) : "..."
1035 }
1036 detail={snapshot ? `${snapshot.power.thermalState} thermal state` : undefined}
1037 tone={speedLimit !== null && speedLimit < 80 ? "warning" : "default"}
1038 />
1039 </div>
1040 {telemetry.error ? (
1041 <div className="flex items-start gap-2 border-t border-destructive/20 bg-destructive/5 px-4 py-3 text-xs text-destructive sm:px-5">
1042 <AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0" />
1043 <span>{telemetry.error}</span>
1044 </div>
1045 ) : null}
1046 {snapshot ? (
1047 <div className="grid border-t border-border/60 bg-muted/10 md:grid-cols-3">
1048 <AggregateCard
1049 label="Backend + agents"
1050 accentClass="bg-emerald-500/80"
1051 aggregate={snapshot.groups.backend}
1052 />
1053 <AggregateCard
1054 label="Desktop"
1055 accentClass="bg-sky-500/80"
1056 aggregate={snapshot.groups.electron}
1057 />
1058 <AggregateCard
1059 label="Monitor overhead"
1060 accentClass="bg-amber-500/80"
1061 aggregate={snapshot.groups.monitor}
1062 />
1063 </div>
1064 ) : null}
1065 </div>
1066 </SettingsSection>
1067
1068 <SettingsSection
1069 title="Host & collection"
1070 icon={<GaugeIcon className="size-4 text-muted-foreground" />}
1071 headerAction={
1072 collectorNeedsRetry ? (
1073 <Button size="xs" variant="outline" disabled={isRetrying} onClick={retryCollector}>
1074 <RotateCcwIcon className={cn("size-3", isRetrying && "animate-spin")} />
1075 Retry monitor
1076 </Button>
1077 ) : null
1078 }
1079 >
1080 <div className="grid overflow-hidden rounded-2xl border border-border/70 bg-card shadow-[0_1px_1px_rgb(0_0_0/0.03)] md:grid-cols-2 md:divide-x md:divide-border/60">
1081 <div className="px-4 py-4 sm:px-5">
1082 <div className="mb-3 flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70">
1083 <span className="flex size-6 items-center justify-center rounded-md bg-muted/60">
1084 <BatteryIcon className="size-3.5" />
1085 </span>
1086 Host state
1087 </div>
1088 {hasHostPowerSignal && snapshot ? (
1089 <>
1090 <DetailRow
1091 label="Power source"
1092 value={booleanStateLabel(snapshot.power.onBattery, {
1093 true: "Battery",
1094 false: "External power",
1095 })}
1096 />
1097 <DetailRow
1098 label="Low power mode"
1099 value={booleanStateLabel(snapshot.power.lowPowerMode, {
1100 true: "Enabled",
1101 false: "Disabled",
1102 })}
1103 />
1104 <DetailRow
1105 label="Idle"
1106 value={`${booleanStateLabel(snapshot.power.idle, {
1107 true: "Idle",
1108 false: "Active",
1109 })}${
1110 snapshot.power.idleSeconds === null
1111 ? ""
1112 : ` · ${Math.round(snapshot.power.idleSeconds)}s`
1113 }`}
1114 />
1115 <DetailRow
1116 label="Session"
1117 value={
1118 snapshot.power.suspended
1119 ? "Suspended"
1120 : booleanStateLabel(snapshot.power.locked, {
1121 true: "Locked",
1122 false: "Unlocked",
1123 })
1124 }
1125 />
1126 <DetailRow
1127 label="Thermal"
1128 value={snapshot.power.thermalState}
1129 valueClassName={
1130 snapshot.power.thermalState === "serious" ||
1131 snapshot.power.thermalState === "critical"
1132 ? "text-destructive"
1133 : undefined
1134 }
1135 />
1136 </>
1137 ) : (
1138 <div className="rounded-xl border border-dashed border-border/70 bg-muted/20 px-4 py-5">
1139 <div className="text-[13px] font-medium text-foreground">
1140 Desktop host signals not connected
1141 </div>
1142 <p className="mt-1.5 max-w-sm text-[11px] leading-relaxed text-muted-foreground/70">
1143 Power, idle, lock, and thermal state are supplied by the desktop host. Process
1144 telemetry remains fully active in this browser session.
1145 </p>
1146 </div>
1147 )}
1148 </div>
1149 <div className="border-t border-border/60 px-4 py-4 md:border-t-0 sm:px-5">
1150 <div className="mb-3 flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70">
1151 <span className="flex size-6 items-center justify-center rounded-md bg-muted/60">
1152 <GaugeIcon className="size-3.5" />
1153 </span>
1154 Collection health
1155 </div>
1156 {snapshot ? (
1157 <>
1158 <HealthSource label="Native process monitor" health={snapshot.health.native} />
1159 <HealthSource label="Electron main process" health={snapshot.health.desktop} />
1160 <DetailRow
1161 label="Collection time"
1162 value={formatDurationMicros(snapshot.health.collectionDurationMicros)}
1163 />
1164 <DetailRow
1165 label="Process scan"
1166 value={`${snapshot.health.retainedProcessCount}/${snapshot.health.scannedProcessCount} retained`}
1167 />
1168 <DetailRow
1169 label="Inaccessible"
1170 value={String(snapshot.health.inaccessibleProcessCount)}
1171 valueClassName={
1172 snapshot.health.inaccessibleProcessCount > 0
1173 ? "text-amber-600 dark:text-amber-300"
1174 : undefined
1175 }
1176 />
1177 <DetailRow
1178 label="Sidecar"
1179 value={Option.match(snapshot.health.sidecarVersion, {
1180 onNone: () => "Unavailable",
1181 onSome: (version) =>
1182 `${version}${Option.match(snapshot.health.sidecarPid, {
1183 onNone: () => "",
1184 onSome: (pid) => ` · PID ${pid}`,
1185 })}`,
1186 })}
1187 />
1188 <DetailRow label="Restarts" value={String(snapshot.health.restartCount)} />
1189 </>
1190 ) : (
1191 <div className="py-4 text-xs text-muted-foreground">
1192 Waiting for collector health.
1193 </div>
1194 )}
1195 </div>
1196 </div>
1197 </SettingsSection>
1198
1199 <SettingsSection
1200 title="Resource timeline"
1201 icon={<HardDriveIcon className="size-4 text-muted-foreground" />}
1202 headerAction={
1203 <div className="flex items-center gap-2">
1204 <HistoryWindowSelector selectedWindowMs={windowMs} onSelect={setWindowMs} />
1205 <Button
1206 size="icon-xs"
1207 variant="ghost"
1208 className="size-5 rounded-sm p-0"
1209 disabled={history.isPending}
1210 onClick={history.refresh}
1211 aria-label="Refresh resource history"
1212 >
1213 <RefreshCwIcon className={cn("size-3", history.isPending && "animate-spin")} />
1214 </Button>
1215 </div>
1216 }
1217 >
1218 <div className="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-[0_1px_1px_rgb(0_0_0/0.03)]">
1219 {history.error ? (
1220 <div className="flex items-start gap-2 border-b border-destructive/20 bg-destructive/5 px-4 py-3 text-xs text-destructive sm:px-5">
1221 <AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0" />
1222 <span>{history.error}</span>
1223 </div>
1224 ) : null}
1225 <ResourceHistoryChart buckets={history.data?.buckets ?? []} />
1226 <HistoryProcessTable processes={history.data?.topProcesses ?? []} />
1227 </div>
1228 </SettingsSection>
1229
1230 <SettingsSection
1231 title="Live process tree"
1232 icon={<CpuIcon className="size-4 text-muted-foreground" />}
1233 headerAction={
1234 snapshot ? (
1235 <span className="text-[10px] text-muted-foreground/55">
1236 Identity: <span className="font-mono">PID + start time</span>
1237 </span>
1238 ) : null
1239 }
1240 >
1241 <div className="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-[0_1px_1px_rgb(0_0_0/0.03)]">
1242 <ProcessTable
1243 processes={snapshot?.processes ?? []}
1244 signalingKeys={signalingKeys}
1245 onSignal={signalProcess}
1246 />
1247 </div>
1248 </SettingsSection>
1249
1250 <SettingsSection
1251 title="Instrumented application I/O"
1252 icon={<DatabaseIcon className="size-4 text-muted-foreground" />}
1253 headerAction={
1254 <span className="text-[10px] text-muted-foreground/55">Logical bytes by operation</span>
1255 }
1256 >
1257 <div className="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-[0_1px_1px_rgb(0_0_0/0.03)]">
1258 <div className="bg-muted/15 px-4 py-3 text-[11px] leading-relaxed text-muted-foreground sm:px-5">
1259 Native counters identify which process is reading or writing. These application-level
1260 counters identify known T3 operations so process spikes can be correlated with specific
1261 persistence and logging paths.
1262 </div>
1263 <AttributionTable entries={snapshot?.attribution.entries ?? []} />
1264 </div>
1265 </SettingsSection>
1266 </>
1267 );
1268}
apps/web/src/components/settings/SettingsPanels.logic.test.ts+109
......@@ -1,17 +1,126 @@
11import {
22 DEFAULT_SERVER_SETTINGS,
3 DEFAULT_UNIFIED_SETTINGS,
34 ProviderDriverKind,
45 ProviderInstanceId,
56 type ProviderInstanceConfig,
67} from "@t3tools/contracts";
8import { getBackgroundActivityPresetSettings } from "@t3tools/shared/backgroundActivitySettings";
9import * as Duration from "effect/Duration";
710import { describe, expect, it } from "vite-plus/test";
811import {
12 backgroundActivitySharedPolicySettings,
913 buildProviderInstanceUpdatePatch,
1014 formatDiagnosticsDescription,
15 hasChangedBackgroundActivitySettings,
1116 isProjectGroupingEnabled,
1217 projectGroupingModeFromToggle,
18 resolveBackgroundActivityProfileOption,
1319} from "./SettingsPanels.logic";
1420
21describe("background activity settings restore", () => {
22 it("detects legacy interval values even when the structured setting is at its default", () => {
23 expect(
24 hasChangedBackgroundActivitySettings({
25 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
26 backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile,
27 automaticGitFetchInterval: Duration.seconds(45),
28 providerHealthRefreshInterval: DEFAULT_UNIFIED_SETTINGS.providerHealthRefreshInterval,
29 }),
30 ).toBe(true);
31 expect(
32 hasChangedBackgroundActivitySettings({
33 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
34 backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile,
35 automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,
36 providerHealthRefreshInterval: Duration.minutes(7),
37 }),
38 ).toBe(true);
39 expect(hasChangedBackgroundActivitySettings(DEFAULT_UNIFIED_SETTINGS)).toBe(false);
40 });
41
42 it("detects a legacy profile override so restoring defaults clears it", () => {
43 expect(
44 hasChangedBackgroundActivitySettings({
45 ...DEFAULT_UNIFIED_SETTINGS,
46 backgroundActivityProfile: "performance",
47 }),
48 ).toBe(true);
49 });
50
51 it("shows the effective legacy preset and marks custom legacy intervals as advanced", () => {
52 const performance = getBackgroundActivityPresetSettings("performance");
53 expect(
54 resolveBackgroundActivityProfileOption({
55 ...DEFAULT_UNIFIED_SETTINGS,
56 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
57 backgroundActivityProfile: "performance",
58 automaticGitFetchInterval: performance.automaticGitFetchInterval,
59 providerHealthRefreshInterval: performance.providerHealthRefreshInterval,
60 }),
61 ).toBe("performance");
62
63 expect(
64 resolveBackgroundActivityProfileOption({
65 ...DEFAULT_UNIFIED_SETTINGS,
66 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
67 backgroundActivityProfile: "performance",
68 automaticGitFetchInterval: Duration.seconds(45),
69 providerHealthRefreshInterval: Duration.minutes(7),
70 }),
71 ).toBe("advanced");
72 });
73
74 it("preserves advanced overrides when the shared policy changes", () => {
75 const automaticGitFetchInterval = Duration.seconds(42);
76 expect(
77 backgroundActivitySharedPolicySettings(
78 {
79 ...DEFAULT_UNIFIED_SETTINGS,
80 backgroundActivity: {
81 schemaVersion: 1,
82 profile: "custom",
83 baseProfile: "balanced",
84 overrides: {
85 automaticGitFetchInterval,
86 pauseWhenOnBattery: true,
87 },
88 },
89 },
90 "performance",
91 ),
92 ).toEqual({
93 schemaVersion: 1,
94 profile: "custom",
95 baseProfile: "performance",
96 overrides: {
97 automaticGitFetchInterval,
98 pauseWhenOnBattery: true,
99 },
100 });
101 });
102
103 it("materializes legacy advanced overrides before changing the shared policy", () => {
104 const automaticGitFetchInterval = Duration.seconds(42);
105 expect(
106 backgroundActivitySharedPolicySettings(
107 {
108 ...DEFAULT_UNIFIED_SETTINGS,
109 automaticGitFetchInterval,
110 },
111 "battery-saver",
112 ),
113 ).toEqual({
114 schemaVersion: 1,
115 profile: "custom",
116 baseProfile: "battery-saver",
117 overrides: {
118 automaticGitFetchInterval,
119 },
120 });
121 });
122});
123
15124describe("project grouping toggle", () => {
16125 it("enables repository grouping and disables into separate projects", () => {
17126 expect(isProjectGroupingEnabled("repository")).toBe(true);
apps/web/src/components/settings/SettingsPanels.logic.ts+67
......@@ -1,4 +1,6 @@
11import type {
2 BackgroundActivityProfile,
3 BackgroundActivitySettings,
24 ProviderDriverKind,
35 ProviderInstanceConfig,
46 ProviderInstanceId,
......@@ -7,6 +9,12 @@ import type {
79 UnifiedSettings,
810} from "@t3tools/contracts";
911import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
12import {
13 normalizeBackgroundActivitySettings,
14 normalizeServerBackgroundActivitySettings,
15 resolveServerBackgroundActivitySettings,
16} from "@t3tools/shared/backgroundActivitySettings";
17import * as Equal from "effect/Equal";
1018
1119export function isProjectGroupingEnabled(mode: SidebarProjectGroupingMode): boolean {
1220 return mode !== "separate";
......@@ -41,6 +49,65 @@ export function rememberEnabledProjectGroupingMode(mode: SidebarProjectGroupingM
4149 }
4250}
4351
52export function hasChangedBackgroundActivitySettings(
53 settings: Pick<
54 UnifiedSettings,
55 | "backgroundActivity"
56 | "backgroundActivityProfile"
57 | "automaticGitFetchInterval"
58 | "providerHealthRefreshInterval"
59 >,
60): boolean {
61 return (
62 !Equal.equals(settings.backgroundActivity, DEFAULT_UNIFIED_SETTINGS.backgroundActivity) ||
63 settings.backgroundActivityProfile !== DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile ||
64 !Equal.equals(
65 settings.automaticGitFetchInterval,
66 DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,
67 ) ||
68 !Equal.equals(
69 settings.providerHealthRefreshInterval,
70 DEFAULT_UNIFIED_SETTINGS.providerHealthRefreshInterval,
71 )
72 );
73}
74
75export function resolveBackgroundActivityProfileOption(
76 settings: ServerSettings,
77): BackgroundActivityProfile | "advanced" {
78 const resolved = resolveServerBackgroundActivitySettings(settings);
79 const normalized = normalizeBackgroundActivitySettings({
80 schemaVersion: 1,
81 profile: "custom",
82 baseProfile: resolved.profile,
83 overrides: {
84 automaticGitFetchInterval: resolved.automaticGitFetchInterval,
85 providerHealthRefreshInterval: resolved.providerHealthRefreshInterval,
86 hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval,
87 hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval,
88 idleClientTtl: resolved.idleClientTtl,
89 pauseWhenHostLocked: resolved.pauseWhenHostLocked,
90 pauseWhenHostLowPower: resolved.pauseWhenHostLowPower,
91 pauseWhenClientLowPower: resolved.pauseWhenClientLowPower,
92 pauseWhenOnBattery: resolved.pauseWhenOnBattery,
93 },
94 });
95 return normalized.profile === "custom" ? "advanced" : normalized.profile;
96}
97
98export function backgroundActivitySharedPolicySettings(
99 settings: ServerSettings,
100 profile: BackgroundActivityProfile,
101): BackgroundActivitySettings {
102 const normalized = normalizeServerBackgroundActivitySettings(settings);
103 return {
104 schemaVersion: 1,
105 profile: "custom",
106 baseProfile: profile,
107 overrides: normalized.profile === "custom" ? normalized.overrides : {},
108 };
109}
110
44111function collapseOtelSignalsUrl(input: {
45112 readonly tracesUrl: string;
46113 readonly metricsUrl: string;
apps/web/src/components/settings/SettingsPanels.tsx+596-6
......@@ -1,10 +1,20 @@
1import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react";
1import {
2 ArchiveIcon,
3 ArchiveX,
4 InfoIcon,
5 LoaderIcon,
6 PlusIcon,
7 RefreshCwIcon,
8 SettingsIcon,
9} from "lucide-react";
210import { Link } from "@tanstack/react-router";
311import type { CSSProperties } from "react";
412import { useCallback, useMemo, useRef, useState } from "react";
513import { useAtomValue } from "@effect/atom-react";
614import {
715 defaultInstanceIdForDriver,
16 type BackgroundActivityProfile,
17 type BackgroundActivitySettings,
818 type DesktopUpdateChannel,
919 PROVIDER_DISPLAY_NAMES,
1020 ProviderDriverKind,
......@@ -27,6 +37,11 @@ import {
2737 MAX_GLASS_OPACITY,
2838 MIN_GLASS_OPACITY,
2939} from "@t3tools/contracts/settings";
40import {
41 getBackgroundActivityBaseProfile,
42 getBackgroundActivityPresetSettings,
43 resolveServerBackgroundActivitySettings,
44} from "@t3tools/shared/backgroundActivitySettings";
3045import { createModelSelection } from "@t3tools/shared/model";
3146import * as Arr from "effect/Array";
3247import * as Duration from "effect/Duration";
......@@ -72,7 +87,23 @@ import { useProjects } from "../../state/entities";
7287import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
7388import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat";
7489import { Button } from "../ui/button";
90import {
91 Dialog,
92 DialogDescription,
93 DialogFooter,
94 DialogHeader,
95 DialogPanel,
96 DialogPopup,
97 DialogTitle,
98} from "../ui/dialog";
7599import { DraftInput } from "../ui/draft-input";
100import {
101 NumberField,
102 NumberFieldDecrement,
103 NumberFieldGroup,
104 NumberFieldIncrement,
105 NumberFieldInput,
106} from "../ui/number-field";
76107import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
77108import { Switch } from "../ui/switch";
78109import { stackedThreadToast, toastManager } from "../ui/toast";
......@@ -88,12 +119,15 @@ import {
88119import { ProviderInstanceCard } from "./ProviderInstanceCard";
89120import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta";
90121import {
122 backgroundActivitySharedPolicySettings,
91123 buildProviderInstanceUpdatePatch,
92124 formatDiagnosticsDescription,
125 hasChangedBackgroundActivitySettings,
93126 isProjectGroupingEnabled,
94127 projectGroupingModeFromToggle,
95128 readLastEnabledProjectGroupingMode,
96129 rememberEnabledProjectGroupingMode,
130 resolveBackgroundActivityProfileOption,
97131} from "./SettingsPanels.logic";
98132import {
99133 SettingResetButton,
......@@ -132,7 +166,129 @@ const TIMESTAMP_FORMAT_LABELS = {
132166 "24-hour": "24-hour",
133167} as const;
134168
169const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record<BackgroundActivityProfile, string> = {
170 balanced: "Balanced",
171 performance: "Performance",
172 "battery-saver": "Battery saver",
173};
174
175type BackgroundActivityProfileOption = BackgroundActivityProfile | "advanced";
176type BackgroundActivityOverridePatch = Partial<{
177 [K in keyof BackgroundActivitySettings["overrides"]]:
178 | BackgroundActivitySettings["overrides"][K]
179 | undefined;
180}>;
181
182const BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS: Record<BackgroundActivityProfileOption, string> = {
183 ...BACKGROUND_ACTIVITY_PROFILE_LABELS,
184 advanced: "Advanced",
185};
186
187const BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS: Record<BackgroundActivityProfile, string> = {
188 balanced:
189 "Pauses background probes when clients are idle, the host is locked, or low power mode is active.",
190 performance: "Allows scoped background probes while any subscribed client remains connected.",
191 "battery-saver": "Also pauses background probes when the host or client is on battery.",
192};
193
194const ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION =
195 "Uses custom background intervals with the selected shared power policy.";
196
197const PROVIDER_HEALTH_INTERVAL_STEP_SECONDS = 30;
135198const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex");
199const BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES: ReadonlyArray<{
200 readonly key:
201 | "pauseWhenHostLocked"
202 | "pauseWhenHostLowPower"
203 | "pauseWhenClientLowPower"
204 | "pauseWhenOnBattery";
205 readonly label: string;
206}> = [
207 { key: "pauseWhenHostLocked", label: "Pause when host is locked" },
208 { key: "pauseWhenHostLowPower", label: "Pause on host low power" },
209 { key: "pauseWhenClientLowPower", label: "Pause on client low power" },
210 { key: "pauseWhenOnBattery", label: "Pause on battery" },
211];
212
213function durationToSeconds(duration: Duration.Duration): number {
214 return Math.round(Duration.toMillis(duration) / 1_000);
215}
216
217function normalizeIntervalSeconds(value: number | null, minimum = 0): number {
218 if (value === null || !Number.isFinite(value)) {
219 return minimum;
220 }
221 return Math.max(minimum, Math.round(value));
222}
223
224function resetBackgroundActivitySettings() {
225 return {
226 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
227 };
228}
229
230function backgroundActivityProfileSettings(profile: BackgroundActivityProfile) {
231 return {
232 backgroundActivity: {
233 schemaVersion: 1 as const,
234 profile,
235 overrides: {},
236 },
237 };
238}
239
240function backgroundActivityOverrideSettings(
241 current: BackgroundActivitySettings,
242 resolved: ReturnType<typeof resolveServerBackgroundActivitySettings>,
243 overrides: BackgroundActivityOverridePatch,
244) {
245 const nextOverrides: BackgroundActivityOverridePatch = {
246 automaticGitFetchInterval: resolved.automaticGitFetchInterval,
247 providerHealthRefreshInterval: resolved.providerHealthRefreshInterval,
248 hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval,
249 hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval,
250 idleClientTtl: resolved.idleClientTtl,
251 pauseWhenHostLocked: resolved.pauseWhenHostLocked,
252 pauseWhenHostLowPower: resolved.pauseWhenHostLowPower,
253 pauseWhenClientLowPower: resolved.pauseWhenClientLowPower,
254 pauseWhenOnBattery: resolved.pauseWhenOnBattery,
255 ...overrides,
256 };
257 for (const [key, value] of Object.entries(nextOverrides)) {
258 if (value === undefined) {
259 delete nextOverrides[key as keyof typeof nextOverrides];
260 }
261 }
262 return {
263 backgroundActivity: {
264 schemaVersion: 1 as const,
265 profile: "custom" as const,
266 baseProfile: getBackgroundActivityBaseProfile(current),
267 overrides: nextOverrides as BackgroundActivitySettings["overrides"],
268 },
269 };
270}
271
272function PolicyTooltip({ children }: { readonly children: string }) {
273 return (
274 <Tooltip>
275 <TooltipTrigger
276 render={
277 <button
278 type="button"
279 className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
280 aria-label="Background policy details"
281 >
282 <InfoIcon className="size-3.5" />
283 </button>
284 }
285 />
286 <TooltipPopup side="top" className="max-w-72">
287 {children}
288 </TooltipPopup>
289 </Tooltip>
290 );
291}
136292
137293function withoutProviderInstanceKey<V>(
138294 record: Readonly<Record<ProviderInstanceId, V>> | undefined,
......@@ -408,6 +564,7 @@ export function useSettingsRestore(onRestored?: () => void) {
408564 settings.textGenerationModelSelection ?? null,
409565 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
410566 );
567 const isBackgroundActivityDirty = hasChangedBackgroundActivitySettings(settings);
411568
412569 const changedSettingLabels = useMemo(
413570 () => [
......@@ -441,10 +598,7 @@ export function useSettingsRestore(onRestored?: () => void) {
441598 DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks
442599 ? ["Provider update checks"]
443600 : []),
444 ...(Duration.toMillis(settings.automaticGitFetchInterval) !==
445 Duration.toMillis(DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval)
446 ? ["Automatic Git fetch interval"]
447 : []),
601 ...(isBackgroundActivityDirty ? ["Background activity"] : []),
448602 ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode
449603 ? ["New thread mode"]
450604 : []),
......@@ -465,6 +619,7 @@ export function useSettingsRestore(onRestored?: () => void) {
465619 ],
466620 [
467621 isTextGenerationModelDirty,
622 isBackgroundActivityDirty,
468623 settings.autoOpenPlanSidebar,
469624 settings.confirmThreadArchive,
470625 settings.confirmThreadDelete,
......@@ -474,7 +629,6 @@ export function useSettingsRestore(onRestored?: () => void) {
474629 settings.diffIgnoreWhitespace,
475630 settings.environmentIdentificationMode,
476631 settings.glassOpacity,
477 settings.automaticGitFetchInterval,
478632 settings.enableAssistantStreaming,
479633 settings.enableProviderUpdateChecks,
480634 settings.sidebarProjectGroupingMode,
......@@ -507,7 +661,10 @@ export function useSettingsRestore(onRestored?: () => void) {
507661 autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar,
508662 enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming,
509663 enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,
664 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
665 backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile,
510666 automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,
667 providerHealthRefreshInterval: DEFAULT_UNIFIED_SETTINGS.providerHealthRefreshInterval,
511668 defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode,
512669 newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin,
513670 addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory,
......@@ -524,6 +681,272 @@ export function useSettingsRestore(onRestored?: () => void) {
524681 };
525682}
526683
684function BackgroundActivityAdvancedDialog({
685 open,
686 onOpenChange,
687}: {
688 readonly open: boolean;
689 readonly onOpenChange: (open: boolean) => void;
690}) {
691 const settings = usePrimarySettings();
692 const updateSettings = useUpdatePrimarySettings();
693 const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);
694 const activeProfile = resolvedBackgroundActivity.profile;
695 const automaticGitFetchIntervalSeconds = durationToSeconds(
696 resolvedBackgroundActivity.automaticGitFetchInterval,
697 );
698 const providerHealthRefreshIntervalSeconds = durationToSeconds(
699 resolvedBackgroundActivity.providerHealthRefreshInterval,
700 );
701 const hostPowerMonitorActiveIntervalSeconds = durationToSeconds(
702 resolvedBackgroundActivity.hostPowerMonitorActiveInterval,
703 );
704 const hostPowerMonitorIdleIntervalSeconds = durationToSeconds(
705 resolvedBackgroundActivity.hostPowerMonitorIdleInterval,
706 );
707
708 return (
709 <Dialog open={open} onOpenChange={onOpenChange}>
710 <DialogPopup className="max-w-xl">
711 <DialogHeader>
712 <DialogTitle>Background Activity</DialogTitle>
713 <DialogDescription>
714 Tune the shared power policy and the background intervals that feed it.
715 </DialogDescription>
716 </DialogHeader>
717 <DialogPanel className="space-y-0 px-6 pb-5">
718 <div className="overflow-hidden rounded-xl border bg-card text-card-foreground">
719 <div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
720 <div className="min-w-0 space-y-1">
721 <div className="text-sm font-medium">Shared policy</div>
722 <p className="text-xs leading-relaxed text-muted-foreground">
723 Controls whether background work may run after a subscribed interval fires.
724 </p>
725 </div>
726 <Select
727 value={activeProfile}
728 onValueChange={(value) => {
729 if (
730 value === "balanced" ||
731 value === "performance" ||
732 value === "battery-saver"
733 ) {
734 updateSettings({
735 backgroundActivity: backgroundActivitySharedPolicySettings(settings, value),
736 });
737 }
738 }}
739 >
740 <SelectTrigger className="w-full sm:w-40" aria-label="Shared background policy">
741 <SelectValue>{BACKGROUND_ACTIVITY_PROFILE_LABELS[activeProfile]}</SelectValue>
742 </SelectTrigger>
743 <SelectPopup align="end" alignItemWithTrigger={false}>
744 <SelectItem hideIndicator value="balanced">
745 {BACKGROUND_ACTIVITY_PROFILE_LABELS.balanced}
746 </SelectItem>
747 <SelectItem hideIndicator value="performance">
748 {BACKGROUND_ACTIVITY_PROFILE_LABELS.performance}
749 </SelectItem>
750 <SelectItem hideIndicator value="battery-saver">
751 {BACKGROUND_ACTIVITY_PROFILE_LABELS["battery-saver"]}
752 </SelectItem>
753 </SelectPopup>
754 </Select>
755 </div>
756
757 <div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
758 <div className="min-w-0 space-y-1">
759 <div className="text-sm font-medium">Git fetch interval</div>
760 <p className="text-xs leading-relaxed text-muted-foreground">
761 Refresh remote branch status in the background.
762 </p>
763 </div>
764 <div className="flex shrink-0 items-center gap-2">
765 <NumberField
766 value={automaticGitFetchIntervalSeconds}
767 min={0}
768 step={5}
769 size="sm"
770 className="w-32"
771 onValueChange={(value) =>
772 updateSettings(
773 backgroundActivityOverrideSettings(
774 settings.backgroundActivity,
775 resolvedBackgroundActivity,
776 {
777 automaticGitFetchInterval: Duration.seconds(
778 normalizeIntervalSeconds(value),
779 ),
780 },
781 ),
782 )
783 }
784 >
785 <NumberFieldGroup>
786 <NumberFieldDecrement aria-label="Decrease Git fetch interval" />
787 <NumberFieldInput aria-label="Git fetch interval in seconds" />
788 <NumberFieldIncrement aria-label="Increase Git fetch interval" />
789 </NumberFieldGroup>
790 </NumberField>
791 <span className="text-xs text-muted-foreground">seconds</span>
792 </div>
793 </div>
794
795 <div className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
796 <div className="min-w-0 space-y-1">
797 <div className="text-sm font-medium">Provider health interval</div>
798 <p className="text-xs leading-relaxed text-muted-foreground">
799 Refresh provider availability, versions, auth state, and model metadata.
800 </p>
801 </div>
802 <div className="flex shrink-0 items-center gap-2">
803 <NumberField
804 value={providerHealthRefreshIntervalSeconds}
805 min={0}
806 step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS}
807 size="sm"
808 className="w-32"
809 onValueChange={(value) =>
810 updateSettings(
811 backgroundActivityOverrideSettings(
812 settings.backgroundActivity,
813 resolvedBackgroundActivity,
814 {
815 providerHealthRefreshInterval: Duration.seconds(
816 normalizeIntervalSeconds(value),
817 ),
818 },
819 ),
820 )
821 }
822 >
823 <NumberFieldGroup>
824 <NumberFieldDecrement aria-label="Decrease provider health interval" />
825 <NumberFieldInput aria-label="Provider health interval in seconds" />
826 <NumberFieldIncrement aria-label="Increase provider health interval" />
827 </NumberFieldGroup>
828 </NumberField>
829 <span className="text-xs text-muted-foreground">seconds</span>
830 </div>
831 </div>
832
833 <div className="flex flex-col gap-3 border-t px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
834 <div className="min-w-0 space-y-1">
835 <div className="text-sm font-medium">Host power monitor</div>
836 <p className="text-xs leading-relaxed text-muted-foreground">
837 Poll host power state while clients are active.
838 </p>
839 </div>
840 <div className="flex shrink-0 items-center gap-2">
841 <NumberField
842 value={hostPowerMonitorActiveIntervalSeconds}
843 min={5}
844 step={5}
845 size="sm"
846 className="w-32"
847 onValueChange={(value) =>
848 updateSettings(
849 backgroundActivityOverrideSettings(
850 settings.backgroundActivity,
851 resolvedBackgroundActivity,
852 {
853 hostPowerMonitorActiveInterval: Duration.seconds(
854 normalizeIntervalSeconds(value, 5),
855 ),
856 },
857 ),
858 )
859 }
860 >
861 <NumberFieldGroup>
862 <NumberFieldDecrement aria-label="Decrease active host power interval" />
863 <NumberFieldInput aria-label="Active host power interval in seconds" />
864 <NumberFieldIncrement aria-label="Increase active host power interval" />
865 </NumberFieldGroup>
866 </NumberField>
867 <span className="text-xs text-muted-foreground">seconds</span>
868 </div>
869 </div>
870
871 <div className="flex flex-col gap-3 border-t px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
872 <div className="min-w-0 space-y-1">
873 <div className="text-sm font-medium">Idle host monitor</div>
874 <p className="text-xs leading-relaxed text-muted-foreground">
875 Poll host power state when no foreground client is active.
876 </p>
877 </div>
878 <div className="flex shrink-0 items-center gap-2">
879 <NumberField
880 value={hostPowerMonitorIdleIntervalSeconds}
881 min={5}
882 step={30}
883 size="sm"
884 className="w-32"
885 onValueChange={(value) =>
886 updateSettings(
887 backgroundActivityOverrideSettings(
888 settings.backgroundActivity,
889 resolvedBackgroundActivity,
890 {
891 hostPowerMonitorIdleInterval: Duration.seconds(
892 normalizeIntervalSeconds(value, 5),
893 ),
894 },
895 ),
896 )
897 }
898 >
899 <NumberFieldGroup>
900 <NumberFieldDecrement aria-label="Decrease idle host power interval" />
901 <NumberFieldInput aria-label="Idle host power interval in seconds" />
902 <NumberFieldIncrement aria-label="Increase idle host power interval" />
903 </NumberFieldGroup>
904 </NumberField>
905 <span className="text-xs text-muted-foreground">seconds</span>
906 </div>
907 </div>
908
909 <div className="grid gap-0 border-t sm:grid-cols-2">
910 {BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES.map(({ key, label }) => (
911 <label
912 key={key}
913 className="flex items-center justify-between gap-3 border-b px-4 py-3 last:border-b-0 sm:border-r sm:even:border-r-0"
914 >
915 <span className="text-sm font-medium">{label}</span>
916 <Switch
917 checked={resolvedBackgroundActivity[key]}
918 onCheckedChange={(checked) =>
919 updateSettings(
920 backgroundActivityOverrideSettings(
921 settings.backgroundActivity,
922 resolvedBackgroundActivity,
923 {
924 [key]: Boolean(checked),
925 },
926 ),
927 )
928 }
929 aria-label={label}
930 />
931 </label>
932 ))}
933 </div>
934 </div>
935 </DialogPanel>
936 <DialogFooter>
937 <Button
938 variant="outline"
939 onClick={() => updateSettings(resetBackgroundActivitySettings())}
940 >
941 Reset all
942 </Button>
943 <Button onClick={() => onOpenChange(false)}>Done</Button>
944 </DialogFooter>
945 </DialogPopup>
946 </Dialog>
947 );
948}
949
527950export function AppearanceSettingsPanel() {
528951 const { theme, setTheme } = useTheme();
529952 const settings = usePrimarySettings();
......@@ -693,6 +1116,7 @@ export function AppearanceSettingsPanel() {
6931116export function GeneralSettingsPanel() {
6941117 const settings = usePrimarySettings();
6951118 const updateSettings = useUpdatePrimarySettings();
1119 const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false);
6961120 const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>(
6971121 readLastEnabledProjectGroupingMode(),
6981122 );
......@@ -728,6 +1152,19 @@ export function GeneralSettingsPanel() {
7281152 settings.textGenerationModelSelection ?? null,
7291153 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
7301154 );
1155 const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);
1156 const activeBackgroundActivityProfile = resolvedBackgroundActivity.profile;
1157 const backgroundActivityProfileOption = resolveBackgroundActivityProfileOption(settings);
1158 const backgroundActivityDescription =
1159 backgroundActivityProfileOption === "advanced"
1160 ? `${ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION} Current shared policy: ${
1161 BACKGROUND_ACTIVITY_PROFILE_LABELS[activeBackgroundActivityProfile]
1162 }.`
1163 : BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS[resolvedBackgroundActivity.profile];
1164 const canResetBackgroundActivity = !Equal.equals(
1165 settings.backgroundActivity,
1166 DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
1167 );
7311168
7321169 return (
7331170 <SettingsPageContainer>
......@@ -890,6 +1327,88 @@ export function GeneralSettingsPanel() {
8901327 }
8911328 />
8921329
1330 <SettingsRow
1331 title={
1332 <span className="inline-flex items-center gap-1.5">
1333 Background activity
1334 <PolicyTooltip>
1335 This shared policy gates background work such as Git refreshes and provider health
1336 probes after their individual intervals elapse.
1337 </PolicyTooltip>
1338 </span>
1339 }
1340 description={backgroundActivityDescription}
1341 resetAction={
1342 canResetBackgroundActivity ? (
1343 <SettingResetButton
1344 label="background activity"
1345 onClick={() => updateSettings(resetBackgroundActivitySettings())}
1346 />
1347 ) : null
1348 }
1349 control={
1350 <>
1351 <Select
1352 value={backgroundActivityProfileOption}
1353 onValueChange={(value) => {
1354 if (value === "advanced") {
1355 setBackgroundActivityDialogOpen(true);
1356 return;
1357 }
1358 if (
1359 value === "balanced" ||
1360 value === "performance" ||
1361 value === "battery-saver"
1362 ) {
1363 updateSettings(backgroundActivityProfileSettings(value));
1364 }
1365 }}
1366 >
1367 <SelectTrigger className="w-full sm:w-40" aria-label="Background activity profile">
1368 <SelectValue>
1369 {BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS[backgroundActivityProfileOption]}
1370 </SelectValue>
1371 </SelectTrigger>
1372 <SelectPopup align="end" alignItemWithTrigger={false}>
1373 <SelectItem hideIndicator value="balanced">
1374 {BACKGROUND_ACTIVITY_PROFILE_LABELS.balanced}
1375 </SelectItem>
1376 <SelectItem hideIndicator value="performance">
1377 {BACKGROUND_ACTIVITY_PROFILE_LABELS.performance}
1378 </SelectItem>
1379 <SelectItem hideIndicator value="battery-saver">
1380 {BACKGROUND_ACTIVITY_PROFILE_LABELS["battery-saver"]}
1381 </SelectItem>
1382 <SelectItem hideIndicator value="advanced">
1383 {BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS.advanced}
1384 </SelectItem>
1385 </SelectPopup>
1386 </Select>
1387 {backgroundActivityProfileOption === "advanced" ? (
1388 <Tooltip>
1389 <TooltipTrigger
1390 render={
1391 <Button
1392 size="icon-sm"
1393 variant="outline"
1394 aria-label="Configure advanced background activity"
1395 onClick={() => setBackgroundActivityDialogOpen(true)}
1396 >
1397 <SettingsIcon className="size-4" />
1398 </Button>
1399 }
1400 />
1401 <TooltipPopup side="top">Configure background activity</TooltipPopup>
1402 </Tooltip>
1403 ) : null}
1404 <BackgroundActivityAdvancedDialog
1405 open={backgroundActivityDialogOpen}
1406 onOpenChange={setBackgroundActivityDialogOpen}
1407 />
1408 </>
1409 }
1410 />
1411
8931412 <SettingsRow
8941413 title="Auto-open task panel"
8951414 description="Open the right-side plan and task panel automatically when steps appear."
......@@ -1207,6 +1726,14 @@ export function ProviderSettingsPanel() {
12071726 );
12081727 const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders);
12091728 const textGenInstanceId = textGenerationModelSelection.instanceId;
1729 const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);
1730 const providerHealthPreset = getBackgroundActivityPresetSettings(
1731 resolvedBackgroundActivity.profile,
1732 ).providerHealthRefreshInterval;
1733 const providerHealthRefreshIntervalSeconds = durationToSeconds(
1734 resolvedBackgroundActivity.providerHealthRefreshInterval,
1735 );
1736 const defaultProviderHealthRefreshIntervalSeconds = durationToSeconds(providerHealthPreset);
12101737 const lastCheckedAt =
12111738 serverProviders.length > 0
12121739 ? serverProviders.reduce(
......@@ -1508,6 +2035,69 @@ export function ProviderSettingsPanel() {
15082035 </div>
15092036 }
15102037 >
2038 <SettingsRow
2039 title={
2040 <span className="inline-flex items-center gap-1.5">
2041 Health check interval
2042 <PolicyTooltip>
2043 This interval is configured here, then the shared Background activity policy decides
2044 whether provider probes may run when the timer fires. Custom intervals appear as
2045 Advanced in General settings.
2046 </PolicyTooltip>
2047 </span>
2048 }
2049 description="Refresh provider availability, versions, auth state, and model metadata in the background. Set this to 0 seconds to rely on manual refreshes."
2050 resetAction={
2051 providerHealthRefreshIntervalSeconds !== defaultProviderHealthRefreshIntervalSeconds ? (
2052 <SettingResetButton
2053 label="provider health check interval"
2054 onClick={() =>
2055 updateSettings(
2056 backgroundActivityOverrideSettings(
2057 settings.backgroundActivity,
2058 resolvedBackgroundActivity,
2059 {
2060 providerHealthRefreshInterval: undefined,
2061 },
2062 ),
2063 )
2064 }
2065 />
2066 ) : null
2067 }
2068 control={
2069 <div className="flex shrink-0 items-center gap-2">
2070 <NumberField
2071 value={providerHealthRefreshIntervalSeconds}
2072 min={0}
2073 step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS}
2074 size="sm"
2075 className="w-32"
2076 onValueChange={(value) =>
2077 updateSettings(
2078 backgroundActivityOverrideSettings(
2079 settings.backgroundActivity,
2080 resolvedBackgroundActivity,
2081 {
2082 providerHealthRefreshInterval: Duration.seconds(
2083 normalizeIntervalSeconds(value),
2084 ),
2085 },
2086 ),
2087 )
2088 }
2089 >
2090 <NumberFieldGroup>
2091 <NumberFieldDecrement aria-label="Decrease provider health check interval" />
2092 <NumberFieldInput aria-label="Provider health check interval in seconds" />
2093 <NumberFieldIncrement aria-label="Increase provider health check interval" />
2094 </NumberFieldGroup>
2095 </NumberField>
2096 <span className="text-xs text-muted-foreground">seconds</span>
2097 </div>
2098 }
2099 />
2100
15112101 {rows.map((row) => {
15122102 const driverOption = getDriverOption(row.driver);
15132103 const liveProvider = serverProviders.find(
apps/web/src/components/settings/SourceControlSettings.tsx+79-13
......@@ -1,8 +1,9 @@
1import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react";
1import { ChevronDownIcon, GitPullRequestIcon, InfoIcon, RefreshCwIcon } from "lucide-react";
22import * as Duration from "effect/Duration";
33import * as Option from "effect/Option";
44import { useState, type ReactNode } from "react";
55import type {
6 BackgroundActivitySettings,
67 SourceControlProviderKind,
78 SourceControlDiscoveryResult,
89 SourceControlProviderAuth,
......@@ -10,7 +11,11 @@ import type {
1011 VcsDriverKind,
1112 VcsDiscoveryItem,
1213} from "@t3tools/contracts";
13import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
14import {
15 getBackgroundActivityBaseProfile,
16 getBackgroundActivityPresetSettings,
17 resolveServerBackgroundActivitySettings,
18} from "@t3tools/shared/backgroundActivitySettings";
1419
1520import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
1621import { cn } from "../../lib/utils";
......@@ -70,6 +75,11 @@ const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = {
7075
7176const SOURCE_CONTROL_SKELETON_ROWS = ["primary", "secondary"] as const;
7277const GIT_FETCH_INTERVAL_STEP_SECONDS = 5;
78type BackgroundActivityOverridePatch = Partial<{
79 [K in keyof BackgroundActivitySettings["overrides"]]:
80 | BackgroundActivitySettings["overrides"][K]
81 | undefined;
82}>;
7383
7484function durationToSeconds(duration: Duration.Duration): number {
7585 return Math.round(Duration.toMillis(duration) / 1_000);
......@@ -82,6 +92,50 @@ function normalizeFetchIntervalSeconds(value: number | null): number {
8292 return Math.max(0, Math.round(value));
8393}
8494
95function backgroundActivityOverrideSettings(
96 current: BackgroundActivitySettings,
97 overrides: BackgroundActivityOverridePatch,
98) {
99 const nextOverrides: BackgroundActivityOverridePatch = {
100 ...current.overrides,
101 ...overrides,
102 };
103 for (const [key, value] of Object.entries(nextOverrides)) {
104 if (value === undefined) {
105 delete nextOverrides[key as keyof typeof nextOverrides];
106 }
107 }
108 return {
109 backgroundActivity: {
110 schemaVersion: 1 as const,
111 profile: "custom" as const,
112 baseProfile: getBackgroundActivityBaseProfile(current),
113 overrides: nextOverrides as BackgroundActivitySettings["overrides"],
114 },
115 };
116}
117
118function BackgroundPolicyTooltip({ children }: { readonly children: string }) {
119 return (
120 <Tooltip>
121 <TooltipTrigger
122 render={
123 <button
124 type="button"
125 className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
126 aria-label="Background policy details"
127 >
128 <InfoIcon className="size-3.5" />
129 </button>
130 }
131 />
132 <TooltipPopup side="top" className="max-w-72">
133 {children}
134 </TooltipPopup>
135 </Tooltip>
136 );
137}
138
85139function optionLabel(value: Option.Option<string>): string | null {
86140 return Option.getOrNull(value);
87141}
......@@ -292,13 +346,16 @@ function DiscoveryItemRow({
292346}
293347
294348function GitFetchIntervalSettings() {
295 const automaticGitFetchInterval = usePrimarySettings(
296 (settings) => settings.automaticGitFetchInterval,
297 );
349 const settings = usePrimarySettings();
298350 const updateSettings = useUpdatePrimarySettings();
299 const automaticGitFetchIntervalSeconds = durationToSeconds(automaticGitFetchInterval);
351 const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);
352 const automaticGitFetchIntervalSeconds = durationToSeconds(
353 resolvedBackgroundActivity.automaticGitFetchInterval,
354 );
300355 const defaultAutomaticGitFetchIntervalSeconds = durationToSeconds(
301 DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,
356 getBackgroundActivityPresetSettings(
357 getBackgroundActivityBaseProfile(settings.backgroundActivity),
358 ).automaticGitFetchInterval,
302359 );
303360 const canResetFetchInterval =
304361 automaticGitFetchIntervalSeconds !== defaultAutomaticGitFetchIntervalSeconds;
......@@ -309,6 +366,11 @@ function GitFetchIntervalSettings() {
309366 <div className="min-w-0 space-y-1">
310367 <div className="flex min-w-0 items-center gap-1">
311368 <span className="text-xs font-medium text-foreground">Fetch interval</span>
369 <BackgroundPolicyTooltip>
370 This interval is configured for Git only. The shared Background activity policy still
371 decides whether Git refreshes may run when the timer fires. Custom intervals appear as
372 Advanced in General settings.
373 </BackgroundPolicyTooltip>
312374 <span
313375 className={cn(
314376 "inline-flex size-5 shrink-0 items-center justify-center transition-opacity",
......@@ -320,9 +382,11 @@ function GitFetchIntervalSettings() {
320382 <SettingResetButton
321383 label="fetch interval"
322384 onClick={() =>
323 updateSettings({
324 automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,
325 })
385 updateSettings(
386 backgroundActivityOverrideSettings(settings.backgroundActivity, {
387 automaticGitFetchInterval: undefined,
388 }),
389 )
326390 }
327391 />
328392 ) : null}
......@@ -341,9 +405,11 @@ function GitFetchIntervalSettings() {
341405 size="sm"
342406 className="w-32"
343407 onValueChange={(value) =>
344 updateSettings({
345 automaticGitFetchInterval: Duration.seconds(normalizeFetchIntervalSeconds(value)),
346 })
408 updateSettings(
409 backgroundActivityOverrideSettings(settings.backgroundActivity, {
410 automaticGitFetchInterval: Duration.seconds(normalizeFetchIntervalSeconds(value)),
411 }),
412 )
347413 }
348414 >
349415 <NumberFieldGroup>
apps/web/src/connection/runtime.ts+19-3
......@@ -5,6 +5,10 @@ import * as Layer from "effect/Layer";
55import { Atom } from "effect/unstable/reactivity";
66
77import { runtimeContextLayer } from "../lib/runtime";
8import {
9 backgroundActivityObserverLayer,
10 backgroundActivityReporterLayer,
11} from "../lib/backgroundActivityReporter";
812import { connectionPlatformLayer } from "./platform";
913
1014const providedConnectionPlatformLayer = connectionPlatformLayer.pipe(
......@@ -17,10 +21,22 @@ type ConnectionLayerSource =
1721 | typeof Connection.layer
1822 | typeof snapshotLoaderLayer
1923 | typeof runtimeContextLayer
20 | typeof connectionPlatformLayer;
24 | typeof connectionPlatformLayer
25 | typeof backgroundActivityObserverLayer
26 | typeof backgroundActivityReporterLayer;
2127
22const connectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe(
23 Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)),
28const providedClientConnectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe(
29 Layer.provideMerge(
30 Layer.mergeAll(
31 runtimeContextLayer,
32 providedConnectionPlatformLayer,
33 backgroundActivityObserverLayer,
34 ),
35 ),
36);
37
38const connectionLayer = backgroundActivityReporterLayer.pipe(
39 Layer.provideMerge(providedClientConnectionLayer),
2440);
2541
2642export const connectionAtomRuntime: Atom.AtomRuntime<
apps/web/src/env.ts+2-4
......@@ -1,8 +1,6 @@
11/**
22 * True when running inside the Electron preload bridge, false in a regular browser.
3 * The preload script sets window.nativeApi via contextBridge before any web-app
3 * The preload script sets window.desktopBridge via contextBridge before any web-app
44 * code executes, so this is reliable at module load time.
55 */
6export const isElectron =
7 typeof window !== "undefined" &&
8 (window.desktopBridge !== undefined || window.nativeApi !== undefined);
6export const isElectron = typeof window !== "undefined" && window.desktopBridge !== undefined;
apps/web/src/environments/primary/httpLayer.ts-1
......@@ -10,7 +10,6 @@ function isSameOriginBrowserPrimary(): boolean {
1010 if (
1111 typeof window === "undefined" ||
1212 window.desktopBridge !== undefined ||
13 window.nativeApi !== undefined ||
1413 !window.location.origin.startsWith("http")
1514 ) {
1615 return false;
apps/web/src/hooks/useSettings.ts-1
......@@ -302,7 +302,6 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
302302 });
303303 }
304304 }
305
306305 if (Object.keys(clientPatch).length > 0) {
307306 persistClientSettings({
308307 ...getClientSettingsSnapshot(),
apps/web/src/lib/backgroundActivityReporter.test.ts created+63
......@@ -0,0 +1,63 @@
1import { EnvironmentId, WS_METHODS } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4
5import {
6 observeBackgroundActivitySubscription,
7 retainedBackgroundScopes,
8 wasRecentlyInteracted,
9} from "./backgroundActivityReporter.ts";
10
11describe("wasRecentlyInteracted", () => {
12 it("expires interaction independently of window focus", () => {
13 expect(wasRecentlyInteracted(10_000, 55_000)).toBe(true);
14 expect(wasRecentlyInteracted(10_000, 55_001)).toBe(false);
15 });
16
17 it("rejects future timestamps", () => {
18 expect(wasRecentlyInteracted(10_001, 10_000)).toBe(false);
19 });
20
21 it.effect("retains an observed subscription until its returned finalizer runs", () =>
22 Effect.gen(function* () {
23 const environmentId = EnvironmentId.make("environment-observation-test");
24 const scope = { type: "vcs-status" as const, cwd: "/repo" };
25 const release = yield* observeBackgroundActivitySubscription({
26 environmentId,
27 method: WS_METHODS.subscribeVcsStatus,
28 input: { cwd: scope.cwd },
29 });
30
31 expect(retainedBackgroundScopes(environmentId)).toEqual([scope]);
32
33 yield* release;
34 expect(retainedBackgroundScopes(environmentId)).toEqual([]);
35 }),
36 );
37
38 it.effect("keeps delimiter-containing environment and scope values distinct", () =>
39 Effect.gen(function* () {
40 const firstEnvironmentId = EnvironmentId.make("a");
41 const secondEnvironmentId = EnvironmentId.make("a:vcs-status:b");
42 const releaseFirst = yield* observeBackgroundActivitySubscription({
43 environmentId: firstEnvironmentId,
44 method: WS_METHODS.subscribeVcsStatus,
45 input: { cwd: "b:vcs-status:c" },
46 });
47 const releaseSecond = yield* observeBackgroundActivitySubscription({
48 environmentId: secondEnvironmentId,
49 method: WS_METHODS.subscribeVcsStatus,
50 input: { cwd: "c" },
51 });
52
53 expect(retainedBackgroundScopes(firstEnvironmentId)).toEqual([
54 { type: "vcs-status", cwd: "b:vcs-status:c" },
55 ]);
56 expect(retainedBackgroundScopes(secondEnvironmentId)).toEqual([
57 { type: "vcs-status", cwd: "c" },
58 ]);
59
60 yield* Effect.all([releaseFirst, releaseSecond]);
61 }),
62 );
63});
apps/web/src/lib/backgroundActivityReporter.ts created+254
......@@ -0,0 +1,254 @@
1import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
2import {
3 EnvironmentRpcSubscriptionObserver,
4 request,
5 type EnvironmentRpcSubscriptionObservation,
6} from "@t3tools/client-runtime/rpc";
7import {
8 type BackgroundScope,
9 type ClientActivityReportInput,
10 type EnvironmentId,
11 WS_METHODS,
12} from "@t3tools/contracts";
13import * as Clock from "effect/Clock";
14import * as DateTime from "effect/DateTime";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17import * as Queue from "effect/Queue";
18import * as Schedule from "effect/Schedule";
19import * as Stream from "effect/Stream";
20import * as SubscriptionRef from "effect/SubscriptionRef";
21
22import { randomUUID } from "./utils";
23
24const CLIENT_ID_STORAGE_KEY = "t3.backgroundActivity.clientId";
25const REPORT_INTERVAL_MS = 25_000;
26const LEASE_TTL_MS = 45_000;
27const RECENT_INTERACTION_WINDOW_MS = LEASE_TTL_MS;
28const BASELINE_SCOPES: ReadonlyArray<BackgroundScope> = [{ type: "provider-status" }];
29
30interface RetainedScope {
31 readonly environmentId: EnvironmentId;
32 readonly scope: BackgroundScope;
33 refCount: number;
34}
35
36const retainedScopes = new Map<string, RetainedScope>();
37const retainedScopeListeners = new Set<() => void>();
38
39function notifyRetainedScopesChanged(): void {
40 for (const listener of retainedScopeListeners) {
41 try {
42 listener();
43 } catch {
44 // A failing observer must not corrupt retained-scope lifetime.
45 }
46 }
47}
48
49function stableScopeKey(environmentId: EnvironmentId, scope: BackgroundScope): string {
50 switch (scope.type) {
51 case "server-config":
52 case "diagnostics":
53 return JSON.stringify([environmentId, scope.type]);
54 case "provider-status":
55 return JSON.stringify([environmentId, scope.type, scope.instanceId ?? null]);
56 case "vcs-status":
57 case "git-refs":
58 return JSON.stringify([environmentId, scope.type, scope.cwd]);
59 case "thread":
60 return JSON.stringify([environmentId, scope.type, scope.threadId]);
61 }
62}
63
64function getClientId(): string {
65 try {
66 const existing = window.localStorage.getItem(CLIENT_ID_STORAGE_KEY);
67 if (existing) return existing;
68 const next = randomUUID();
69 window.localStorage.setItem(CLIENT_ID_STORAGE_KEY, next);
70 return next;
71 } catch {
72 return "ephemeral-browser-client";
73 }
74}
75
76function resolveClientKind(): ClientActivityReportInput["clientKind"] {
77 return window.desktopBridge ? "desktop-renderer" : "web";
78}
79
80export function wasRecentlyInteracted(lastInteractionAtMs: number, observedAtMs: number): boolean {
81 return (
82 lastInteractionAtMs <= observedAtMs &&
83 observedAtMs - lastInteractionAtMs <= RECENT_INTERACTION_WINDOW_MS
84 );
85}
86
87function createActivityReport(
88 environmentId: EnvironmentId,
89 lastInteractionAtMs: number,
90 observedAtMs: number,
91): ClientActivityReportInput {
92 const scopes = [...BASELINE_SCOPES];
93 for (const entry of retainedScopes.values()) {
94 if (entry.environmentId === environmentId) {
95 scopes.push(entry.scope);
96 }
97 }
98 return {
99 environmentId,
100 clientId: getClientId(),
101 clientKind: resolveClientKind(),
102 visible: document.visibilityState === "visible",
103 focused: document.hasFocus(),
104 recentlyInteracted: wasRecentlyInteracted(lastInteractionAtMs, observedAtMs),
105 appState: document.visibilityState === "visible" ? "active" : "background",
106 scopes,
107 ttlMs: LEASE_TTL_MS,
108 observedAt: DateTime.makeUnsafe(observedAtMs),
109 };
110}
111
112function scopeForSubscription(
113 observation: EnvironmentRpcSubscriptionObservation,
114): BackgroundScope | null {
115 if (observation.method === WS_METHODS.subscribeResourceTelemetry) {
116 return { type: "diagnostics" };
117 }
118 if (observation.method !== WS_METHODS.subscribeVcsStatus) {
119 return null;
120 }
121 const input = observation.input as { readonly cwd?: unknown };
122 return typeof input.cwd === "string" ? { type: "vcs-status", cwd: input.cwd } : null;
123}
124
125function retainBackgroundScope(environmentId: EnvironmentId, scope: BackgroundScope): () => void {
126 const key = stableScopeKey(environmentId, scope);
127 const existing = retainedScopes.get(key);
128 if (existing) {
129 existing.refCount += 1;
130 } else {
131 retainedScopes.set(key, { environmentId, scope, refCount: 1 });
132 notifyRetainedScopesChanged();
133 }
134
135 return () => {
136 const current = retainedScopes.get(key);
137 if (!current) return;
138 current.refCount -= 1;
139 if (current.refCount <= 0) {
140 retainedScopes.delete(key);
141 notifyRetainedScopesChanged();
142 }
143 };
144}
145
146export function observeBackgroundActivitySubscription(
147 observation: EnvironmentRpcSubscriptionObservation,
148): Effect.Effect<Effect.Effect<void>> {
149 const scope = scopeForSubscription(observation);
150 if (scope === null) {
151 return Effect.succeed(Effect.void);
152 }
153 return Effect.sync(() => {
154 const release = retainBackgroundScope(observation.environmentId as EnvironmentId, scope);
155 return Effect.sync(release);
156 });
157}
158
159export function retainedBackgroundScopes(
160 environmentId: EnvironmentId,
161): ReadonlyArray<BackgroundScope> {
162 return Array.from(retainedScopes.values(), (entry) =>
163 entry.environmentId === environmentId ? entry.scope : null,
164 ).filter((scope): scope is BackgroundScope => scope !== null);
165}
166
167export const backgroundActivityObserverLayer = Layer.succeed(
168 EnvironmentRpcSubscriptionObserver,
169 EnvironmentRpcSubscriptionObserver.of({
170 observe: observeBackgroundActivitySubscription,
171 }),
172);
173
174export const backgroundActivityReporterLayer = Layer.effectDiscard(
175 Effect.gen(function* () {
176 if (typeof window === "undefined" || typeof document === "undefined") {
177 return;
178 }
179
180 const registry = yield* EnvironmentRegistry;
181 const clock = yield* Clock.Clock;
182 const reportRequests = yield* Queue.sliding<void>(1);
183 const requestReport = () => Queue.offerUnsafe(reportRequests, undefined);
184 let lastInteractionAtMs = clock.currentTimeMillisUnsafe();
185 const recordInteraction = () => {
186 const observedAtMs = clock.currentTimeMillisUnsafe();
187 const wasRecent = wasRecentlyInteracted(lastInteractionAtMs, observedAtMs);
188 lastInteractionAtMs = observedAtMs;
189 if (!wasRecent) {
190 requestReport();
191 }
192 };
193 const passiveListenerOptions = { passive: true } as const;
194
195 const report = Effect.gen(function* () {
196 const observedAtMs = yield* Clock.currentTimeMillis;
197 const entries = yield* SubscriptionRef.get(registry.entries);
198 yield* Effect.forEach(
199 entries.keys(),
200 (environmentId) =>
201 registry
202 .run(
203 environmentId,
204 request(
205 WS_METHODS.serverReportClientActivity,
206 createActivityReport(environmentId, lastInteractionAtMs, observedAtMs),
207 ),
208 )
209 .pipe(Effect.ignore),
210 { concurrency: "unbounded", discard: true },
211 );
212 }).pipe(Effect.withSpan("web.backgroundActivity.report"));
213
214 yield* Effect.acquireRelease(
215 Effect.sync(() => {
216 retainedScopeListeners.add(requestReport);
217 document.addEventListener("visibilitychange", requestReport);
218 window.addEventListener("focus", requestReport);
219 window.addEventListener("blur", requestReport);
220 window.addEventListener("online", requestReport);
221 window.addEventListener("pointermove", recordInteraction);
222 window.addEventListener("keydown", recordInteraction);
223 window.addEventListener("wheel", recordInteraction, passiveListenerOptions);
224 window.addEventListener("touchstart", recordInteraction, passiveListenerOptions);
225 }),
226 () =>
227 Effect.sync(() => {
228 retainedScopeListeners.delete(requestReport);
229 document.removeEventListener("visibilitychange", requestReport);
230 window.removeEventListener("focus", requestReport);
231 window.removeEventListener("blur", requestReport);
232 window.removeEventListener("online", requestReport);
233 window.removeEventListener("pointermove", recordInteraction);
234 window.removeEventListener("keydown", recordInteraction);
235 window.removeEventListener("wheel", recordInteraction);
236 window.removeEventListener("touchstart", recordInteraction);
237 }),
238 );
239
240 yield* SubscriptionRef.changes(registry.entries).pipe(
241 Stream.runForEach(() => Effect.sync(requestReport)),
242 Effect.forkScoped,
243 );
244 yield* Stream.fromQueue(reportRequests).pipe(
245 Stream.debounce("250 millis"),
246 Stream.runForEach(() => report),
247 Effect.forkScoped,
248 );
249 yield* Effect.sync(requestReport).pipe(
250 Effect.repeat(Schedule.spaced(`${REPORT_INTERVAL_MS} millis`)),
251 Effect.forkScoped,
252 );
253 }),
254);
apps/web/src/lib/resourceTelemetryState.ts created+51
......@@ -0,0 +1,51 @@
1import type { ResourceTelemetryHistoryInput, ResourceTelemetrySnapshot } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
3import { useCallback } from "react";
4
5import { usePrimaryEnvironment } from "../state/environments";
6import { useEnvironmentQuery } from "../state/query";
7import { serverEnvironment } from "../state/server";
8import { useAtomCommand } from "../state/use-atom-command";
9
10export interface ResourceTelemetryState {
11 readonly data: ResourceTelemetrySnapshot | null;
12 readonly error: string | null;
13 readonly isPending: boolean;
14 readonly refresh: () => void;
15 readonly retry: () => Promise<ResourceTelemetrySnapshot>;
16}
17
18export function useResourceTelemetry(): ResourceTelemetryState {
19 const primaryEnvironment = usePrimaryEnvironment();
20 const environmentId = primaryEnvironment?.environmentId ?? null;
21 const query = useEnvironmentQuery(
22 environmentId === null
23 ? null
24 : serverEnvironment.resourceTelemetry({ environmentId, input: {} }),
25 );
26 const retryCommand = useAtomCommand(serverEnvironment.retryResourceTelemetry, {
27 reportFailure: false,
28 });
29 const retry = useCallback(async () => {
30 if (environmentId === null) {
31 throw new Error("No environment is selected.");
32 }
33 const result = await retryCommand({ environmentId, input: {} });
34 if (result._tag === "Failure") {
35 throw Cause.squash(result.cause);
36 }
37 return result.value.snapshot;
38 }, [environmentId, retryCommand]);
39
40 return { ...query, retry };
41}
42
43export function useResourceTelemetryHistory(input: ResourceTelemetryHistoryInput) {
44 const primaryEnvironment = usePrimaryEnvironment();
45 const environmentId = primaryEnvironment?.environmentId ?? null;
46 return useEnvironmentQuery(
47 environmentId === null
48 ? null
49 : serverEnvironment.resourceTelemetryHistory({ environmentId, input }),
50 );
51}
apps/web/src/localApi.test.ts+3-16
......@@ -49,7 +49,6 @@ beforeEach(() => {
4949 });
5050 }
5151 Reflect.deleteProperty(testWindow(), "desktopBridge");
52 Reflect.deleteProperty(testWindow(), "nativeApi");
5352 Object.defineProperty(testWindow(), "localStorage", {
5453 configurable: true,
5554 value: createLocalStorageStub(),
......@@ -61,16 +60,12 @@ afterEach(() => {
6160});
6261
6362describe("LocalApi", () => {
64 it("keeps backend operations unavailable in the browser facade", async () => {
63 it("keeps backend operations out of the local host facade", async () => {
6564 const { createLocalApi } = await import("./localApi");
6665 const api = createLocalApi();
6766
68 await expect(api.server.getConfig()).rejects.toThrow(
69 "Local backend API is unavailable before a backend is paired.",
70 );
71 await expect(api.shell.openInEditor("/tmp", "cursor")).rejects.toThrow(
72 "Local backend API is unavailable before a backend is paired.",
73 );
67 expect(api).not.toHaveProperty("server");
68 expect(api.shell).not.toHaveProperty("openInEditor");
7469 });
7570
7671 it("uses the browser context-menu fallback without a desktop bridge", async () => {
......@@ -120,12 +115,4 @@ describe("LocalApi", () => {
120115 await api.persistence.setClientSettings(settings);
121116 await expect(api.persistence.getClientSettings()).resolves.toEqual(settings);
122117 });
123
124 it("prefers the native LocalApi when one is injected", async () => {
125 const nativeApi = { dialogs: {} };
126 testWindow().nativeApi = nativeApi as never;
127 const { readLocalApi } = await import("./localApi");
128
129 expect(readLocalApi()).toBe(nativeApi);
130 });
131118});
apps/web/src/localApi.ts+1-25
......@@ -6,10 +6,6 @@ import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientP
66
77let cachedApi: LocalApi | undefined;
88
9function unavailableLocalBackendError(): Error {
10 return new Error("Local backend API is unavailable before a backend is paired.");
11}
12
139function createBrowserLocalApi(): LocalApi {
1410 return {
1511 dialogs: {
......@@ -25,7 +21,6 @@ function createBrowserLocalApi(): LocalApi {
2521 },
2622 },
2723 shell: {
28 openInEditor: () => Promise.reject(unavailableLocalBackendError()),
2924 openExternal: async (url) => {
3025 if (window.desktopBridge) {
3126 const opened = await window.desktopBridge.openExternal(url);
......@@ -63,20 +58,6 @@ function createBrowserLocalApi(): LocalApi {
6358 writeBrowserClientSettings(settings);
6459 },
6560 },
66 server: {
67 getConfig: () => Promise.reject(unavailableLocalBackendError()),
68 refreshProviders: () => Promise.reject(unavailableLocalBackendError()),
69 updateProvider: () => Promise.reject(unavailableLocalBackendError()),
70 upsertKeybinding: () => Promise.reject(unavailableLocalBackendError()),
71 removeKeybinding: () => Promise.reject(unavailableLocalBackendError()),
72 getSettings: () => Promise.reject(unavailableLocalBackendError()),
73 updateSettings: () => Promise.reject(unavailableLocalBackendError()),
74 discoverSourceControl: () => Promise.reject(unavailableLocalBackendError()),
75 getTraceDiagnostics: () => Promise.reject(unavailableLocalBackendError()),
76 getProcessDiagnostics: () => Promise.reject(unavailableLocalBackendError()),
77 getProcessResourceHistory: () => Promise.reject(unavailableLocalBackendError()),
78 signalProcess: () => Promise.reject(unavailableLocalBackendError()),
79 },
8061 };
8162}
8263
......@@ -88,12 +69,7 @@ export function readLocalApi(): LocalApi | undefined {
8869 if (typeof window === "undefined") return undefined;
8970 if (cachedApi) return cachedApi;
9071
91 if (window.nativeApi) {
92 cachedApi = window.nativeApi;
93 return cachedApi;
94 }
95
96 cachedApi = createBrowserLocalApi();
72 cachedApi = createLocalApi();
9773 return cachedApi;
9874}
9975
apps/web/src/vite-env.d.ts+1-2
......@@ -1,6 +1,6 @@
11/// <reference types="vite-plus/client" />
22
3import type { DesktopBridge, LocalApi } from "@t3tools/contracts";
3import type { DesktopBridge } from "@t3tools/contracts";
44
55interface ImportMetaEnv {
66 readonly VITE_HTTP_URL: string;
......@@ -22,7 +22,6 @@ interface ImportMeta {
2222
2323declare global {
2424 interface Window {
25 nativeApi?: LocalApi;
2625 desktopBridge?: DesktopBridge;
2726 }
2827}
docs/architecture/overview.md+4
......@@ -39,6 +39,10 @@ T3 Code runs as a **Node.js WebSocket server** that wraps `codex app-server` (JS
3939
4040- **Server updates**: A connected environment advertises whether its server can replace itself. When client and server versions differ, the browser selects an automatic, desktop-managed, or manual update path without changing connection ownership. See [Server Update Architecture](./server-updates.md).
4141
42Related design:
43
44- [Resource telemetry architecture](./resource-telemetry.md)
45
4246## Event Lifecycle
4347
4448### Startup and client connect
docs/architecture/resource-telemetry.md created+391
......@@ -0,0 +1,391 @@
1# Resource telemetry architecture
2
3Status: implemented
4
5## Purpose
6
7Resource telemetry replaces recurring `ps`, PowerShell, `ioreg`, and `pmset`
8subprocess probes with two persistent, direct data sources:
9
101. a standalone Rust resource-monitor executable that reads process counters
11 through operating-system APIs via `sysinfo`;
122. Electron main-process APIs for Electron process metrics and host power state.
13
14The native monitor owns bounded in-memory history. The server only merges and
15summarizes that history when diagnostics requests it. Telemetry history is not
16persisted to disk or continuously copied into Node.
17
18## Why a standalone executable
19
20The monitor is intentionally not a Node native addon.
21
22- No N-API, `ffi-rs`, or dynamic-library ABI is loaded into the server process.
23- A monitor crash cannot corrupt the Node runtime.
24- The server can supervise, restart, version-check, and measure the monitor as a
25 normal child process.
26- The same protocol works for the desktop app and the published CLI.
27- Packaging is a single platform executable instead of an addon toolchain plus
28 Node/Electron ABI matrix.
29
30The cost is one persistent child process and NDJSON serialization. That is a
31better failure boundary than repeatedly spawning shell utilities or loading
32native code into Node.
33
34## Runtime topology
35
36### Desktop
37
38```text
39Electron main
40 ├─ powerMonitor
41 ├─ app.getAppMetrics() while diagnostics is open
42 ├─ inherited fd 4, telemetry NDJSON ─────────────┐
43 └─ inherited fd 5, demand-control NDJSON â—€──────┤
44 â–¼
45Node server ── stdin/stdout NDJSON ── Rust resource monitor
46 │
47 ├─ ResourceTelemetry Effect service
48 ├─ background power policy projection
49 └─ WebSocket RPC/subscription ── diagnostics UI
50```
51
52### Web, headless, and remote server
53
54Electron telemetry is unavailable. The native monitor still runs beside the
55server and tracks the server process tree. Power fields degrade to `unknown`
56instead of invoking platform shell commands.
57
58### WSL backend limitation
59
60Windows desktop packages currently ship the Windows resource-monitor executable.
61That executable cannot run inside the Linux WSL backend, so a WSL-only backend
62does not receive `resourceMonitorPath` and reports native process telemetry as
63unavailable. Electron host-power telemetry remains available over the inherited
64desktop pipe. Supporting native WSL process telemetry requires publishing a
65Linux sidecar for each supported architecture in the Windows artifact and
66converting its packaged path into the selected distro; the configuration
67deliberately does not pass the Windows `.exe` into WSL as a false fallback.
68
69## Native monitor
70
71The executable lives in `native/resource-monitor`.
72
73It receives schema-compatible commands on stdin and emits one JSON object per
74line on stdout:
75
76- `configure`
77- `setExternalProcesses`
78- `setSampleInterval`
79- `setStreaming`
80- `sampleNow`
81- `readHistory`
82- `shutdown`
83- `hello`
84- `snapshot`
85- `historyChunk`
86- `error`
87
88The protocol version is defined by
89`RESOURCE_MONITOR_PROTOCOL_VERSION` in
90`packages/contracts/src/resourceTelemetry.ts`.
91
92### Collection
93
94The monitor keeps one `sysinfo::System` instance and refreshes it at the
95power-adaptive interval selected by the server. It collects:
96
97- PID and parent PID;
98- process start time and run time;
99- process name and command line;
100- current and cumulative CPU usage;
101- resident and virtual memory;
102- cumulative process I/O counters.
103
104On Linux, task/thread enumeration is disabled. Command lines are loaded only
105when first needed. This avoids the expensive default behavior of walking every
106`/proc/<pid>/task/<tid>` directory on each refresh.
107
108### Process-tree selection
109
110Each sample scans the accessible process table, builds the PID/PPID graph, and
111retains:
112
113- the server process;
114- every descendant of the server, including provider-spawned grandchildren such
115 as shells, `node`, `tsgo`, language servers, and other tools;
116- Electron processes supplied as explicit external roots;
117- descendants of those Electron roots;
118- the resource monitor itself, because it is a server child.
119
120Process identity is `(pid, startTimeMs)`, not PID alone. Electron and native
121start times are matched with a two-second tolerance because native start times
122can have coarser platform resolution.
123
124The process list is emitted in depth-first tree order so renderer collapse and
125expansion preserves complete subtrees.
126
127### Native history and streaming
128
129Every native sample is appended to a one-hour in-memory ring bounded to 3,600
130snapshots, 20,000 retained process rows, and 64 MiB of retained history bytes.
131History stays in the sidecar until a `readHistory` request and is returned in
132bounded chunks. The first bound reached wins, so high process counts or large
133process names and command lines shorten the effective history window.
134
135Periodic snapshot streaming is disabled by default. The server enables it only
136while at least one diagnostics subscription is retained. `sampleNow` remains
137available for explicit refreshes and identity validation.
138
139The server adjusts native sampling without restarting the sidecar:
140
141- suspended, locked, low-power, or serious/critical thermal state: 15 seconds;
142- battery: 5 seconds;
143- normal AC: 1 second;
144- unknown or stale power: 5 seconds in the background and 1 second while live
145 diagnostics is open.
146
147### Sampling limits
148
149This is counter sampling, not syscall tracing.
150
151- A process that starts and exits entirely between samples may not be observed.
152- Cumulative CPU and I/O counters still provide accurate deltas for processes
153 that survive across samples.
154- Exact file paths, individual write syscalls, ETW events, eBPF events, and
155 Endpoint Security events are outside this implementation.
156
157Those deeper tracing systems can be added later as opt-in diagnostic modes
158without changing the public `ResourceTelemetry` model.
159
160## I/O semantics
161
162The monitor preserves platform semantics instead of presenting all counters as
163equivalent:
164
165- Unix-like platforms report storage I/O counters exposed by `sysinfo`.
166- Windows reports all process I/O bytes, not only disk bytes.
167- Operating-system caches can prevent logical application reads or writes from
168 appearing as physical storage bytes.
169
170The UI therefore labels these values as I/O reads and writes and exposes the
171per-process `ioSemantics` value.
172
173Group totals are observed deltas since telemetry startup. Per-process total
174columns are the operating system's cumulative counters for that process.
175
176## Electron telemetry
177
178Electron main owns `DesktopTelemetryPublisher`.
179
180Power events trigger an immediate snapshot. While diagnostics is closed, the
181server sends the configured active and idle host-power intervals to Electron
182(30 seconds and 2 minutes in the balanced profile). During those heartbeats
183Electron reads:
184
185- `powerMonitor.isOnBatteryPower()`;
186- `powerMonitor.getSystemIdleTime()`;
187- `powerMonitor.getSystemIdleState()`;
188- `powerMonitor.getCurrentThermalState()`.
189
190`app.getAppMetrics()` is only called while diagnostics demand is active. Its
191live cadence is 1 second on AC, 5 seconds on battery, and 15 seconds while
192locked, suspended, or thermally constrained.
193
194It also listens for:
195
196- lock and unlock;
197- suspend and resume;
198- AC and battery transitions;
199- thermal-state changes;
200- CPU speed-limit changes.
201
202Suspension stays latched across event-driven snapshots. An explicit resume
203clears it immediately; a periodic heartbeat also clears a stale latch so a
204missed resume event cannot leave telemetry permanently constrained.
205
206Electron does not expose a cross-platform low-power-mode getter, so that field
207remains `unknown`.
208
209The desktop backend is spawned with:
210
211- fd 3 for the existing bootstrap payload;
212- fd 4 for Electron-to-server telemetry NDJSON;
213- fd 5 for server-to-Electron diagnostics-demand NDJSON.
214
215These are private Electron-main/server pipes. They do not use the renderer
216WebSocket and are recreated for every backend restart.
217
218## Server Effect services
219
220The implementation is under `apps/server/src/resourceTelemetry`.
221
222### `ResourceMonitorBinary`
223
224Resolves an executable from:
225
2261. `T3CODE_RESOURCE_MONITOR_PATH`;
2272. desktop bootstrap configuration;
2283. bundled CLI resources;
2294. local Cargo build outputs.
230
231Unsupported platforms, missing binaries, and non-executable binaries use
232schema-backed tagged errors with descriptive messages.
233
234### `NativeTelemetryClient`
235
236Owns the resource-monitor process and protocol.
237
238- validates the hello/version handshake;
239- sends configuration and external process roots;
240- adapts the native interval from host power state;
241- enables streaming only for scoped live subscribers;
242- reads chunked native history on demand;
243- exposes `sampleNow`;
244- serializes commands;
245- supervises process exit and protocol failure;
246- restarts with bounded exponential backoff;
247- opens a circuit after repeated failures;
248- supports explicit retry;
249- publishes health changes immediately.
250
251Snapshot sequence numbers are scoped to a monitor generation. Server ingestion
252uses the monitor restart count as the generation key, so sequence reset after a
253restart cannot freeze telemetry.
254
255### `DesktopTelemetryReceiver`
256
257Reads fd 4, decodes schema-validated messages, stores the latest Electron
258snapshot, and publishes desktop health. It writes diagnostics demand to fd 5
259and gives the first sample a 90-second startup deadline. Once samples arrive,
260the stale deadline stays beyond the slower configured host-power interval with
26130 seconds of scheduling grace, so intentional 2–10 minute idle polling does
262not oscillate the policy between constrained and unconstrained states. Decode
263errors, protocol mismatch, control-write failure, stream failure, stale input,
264and normal stream closure are represented explicitly.
265
266### `ResourceTelemetry`
267
268Merges native and Electron data and owns public telemetry semantics.
269
270- calculates CPU and I/O rates from cumulative native counters;
271- preserves the last native rates during desktop-only updates;
272- classifies backend, Electron, and monitor processes;
273- computes process depth and child relationships;
274- tracks starts, exits, CPU time, and observed I/O;
275- projects power data;
276- acquires native streaming and Electron process metrics only for scoped live
277 subscribers;
278- queries and replays native history only when requested;
279- validates `(pid, startTimeMs)` before process signaling;
280- updates history health even when no further native sample arrives.
281
282Electron and monitor processes are visible but are not valid targets for the
283existing process-signal RPC.
284
285### History projection
286
287`ResourceTelemetryHistory` is a pure on-demand projection. It replays raw native
288snapshots to derive rates, lifecycle counters, buckets, and process summaries.
289Current Electron process metrics are intentionally excluded from historical
290replay so they cannot overwrite older native CPU or memory samples.
291
292### `ResourceAttribution`
293
294Tracks known logical application I/O separately from OS counters. Current
295integration points record successful writes for:
296
297- provider native and canonical event logs;
298- the local server trace sink.
299
300Entries contain component, operation, logical bytes, count, and elapsed time.
301Future persistence paths should call `ResourceAttribution.record` rather than
302adding diagnostics-specific counters.
303
304## Background policy integration
305
306`HostPowerMonitor` consumes `DesktopTelemetryReceiver` directly; observing host
307power does not retain live resource diagnostics or invoke shell probes.
308
309The monitor updates its latest timestamp on every Electron sample but only
310publishes semantic state changes. Increasing idle seconds alone does not cause a
311background-policy broadcast every second.
312
313## Public API and UI
314
315The WebSocket RPC surface provides:
316
317- current snapshot;
318- bounded history;
319- explicit monitor retry;
320- a live snapshot subscription.
321
322The diagnostics page displays:
323
324- aggregate CPU, memory, I/O, and process counts;
325- backend, Electron, and monitor overhead groups;
326- power and thermal state;
327- collector health and restart information;
328- CPU and I/O history;
329- a collapsible live process tree;
330- safe process signaling for backend descendants;
331- instrumented logical application I/O.
332
333Legacy process diagnostics RPCs are projected from the same service so they no
334longer start recurring process-table commands.
335
336## Packaging
337
338Desktop artifact builds compile the Rust target, stage it as
339`resources/resource-monitor/t3-resource-monitor[.exe]`, and pass its path to the
340backend bootstrap.
341
342CLI release jobs upload each active platform monitor artifact and copy it into:
343
344```text
345apps/server/dist/resource-monitor/<platform>-<arch>/
346```
347
348The published server package already includes `dist`, so those executables ship
349with the CLI. Missing platform artifacts degrade native telemetry to
350`unavailable`; the server continues running.
351
352## Resource and failure behavior
353
354Steady state uses:
355
356- one native process;
357- power-adaptive native counter sampling with no periodic Node snapshot stream;
358- event-driven Electron power updates plus profile-driven heartbeats (30–60
359 seconds while active and 2–10 minutes while idle);
360- no `app.getAppMetrics()` calls while diagnostics is closed;
361- no telemetry database;
362- no recurring shell probes;
363- bounded PubSub queues and native ring history.
364
365The diagnostics page exposes the monitor's own process resource usage and
366collection duration so the observer's cost is measurable.
367
368Failures are isolated:
369
370- native failure does not stop the server;
371- Electron telemetry loss does not stop native telemetry;
372- schema/version errors are visible in health;
373- repeated native failures stop automatic restart churn until explicit retry;
374- server and desktop shutdown close their respective streams and child process
375 scopes.
376
377## Future integration points
378
379High-value follow-up work can use the existing service boundaries:
380
381- opt-in file-path attribution through platform-specific tracing;
382- process lifecycle events to reduce the chance of missing very short-lived
383 children;
384- additional `ResourceAttribution` instrumentation for databases, checkpoints,
385 caches, and file synchronization;
386- exported diagnostic bundles;
387- adaptive sample intervals based on diagnostics visibility and active work.
388
389These additions should preserve the current rules: direct platform APIs,
390schema-validated boundaries, explicit metric semantics, bounded retention, and
391no mandatory telemetry persistence.
native/resource-monitor/Cargo.lock created+343
......@@ -0,0 +1,343 @@
1# This file is automatically @generated by Cargo.
2# It is not intended for manual editing.
3version = 4
4
5[[package]]
6name = "bitflags"
7version = "2.13.0"
8source = "registry+https://github.com/rust-lang/crates.io-index"
9checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
10
11[[package]]
12name = "dispatch2"
13version = "0.3.1"
14source = "registry+https://github.com/rust-lang/crates.io-index"
15checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
16dependencies = [
17 "bitflags",
18 "objc2",
19]
20
21[[package]]
22name = "itoa"
23version = "1.0.18"
24source = "registry+https://github.com/rust-lang/crates.io-index"
25checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
26
27[[package]]
28name = "libc"
29version = "0.2.186"
30source = "registry+https://github.com/rust-lang/crates.io-index"
31checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
32
33[[package]]
34name = "memchr"
35version = "2.8.2"
36source = "registry+https://github.com/rust-lang/crates.io-index"
37checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
38
39[[package]]
40name = "ntapi"
41version = "0.4.3"
42source = "registry+https://github.com/rust-lang/crates.io-index"
43checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
44dependencies = [
45 "winapi",
46]
47
48[[package]]
49name = "objc2"
50version = "0.6.4"
51source = "registry+https://github.com/rust-lang/crates.io-index"
52checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
53dependencies = [
54 "objc2-encode",
55]
56
57[[package]]
58name = "objc2-core-foundation"
59version = "0.3.2"
60source = "registry+https://github.com/rust-lang/crates.io-index"
61checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
62dependencies = [
63 "bitflags",
64 "dispatch2",
65 "objc2",
66]
67
68[[package]]
69name = "objc2-encode"
70version = "4.1.0"
71source = "registry+https://github.com/rust-lang/crates.io-index"
72checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
73
74[[package]]
75name = "objc2-foundation"
76version = "0.3.2"
77source = "registry+https://github.com/rust-lang/crates.io-index"
78checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
79dependencies = [
80 "bitflags",
81 "objc2",
82]
83
84[[package]]
85name = "objc2-io-kit"
86version = "0.3.2"
87source = "registry+https://github.com/rust-lang/crates.io-index"
88checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
89dependencies = [
90 "libc",
91 "objc2-core-foundation",
92]
93
94[[package]]
95name = "objc2-open-directory"
96version = "0.3.2"
97source = "registry+https://github.com/rust-lang/crates.io-index"
98checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d"
99dependencies = [
100 "objc2",
101 "objc2-core-foundation",
102 "objc2-foundation",
103]
104
105[[package]]
106name = "proc-macro2"
107version = "1.0.106"
108source = "registry+https://github.com/rust-lang/crates.io-index"
109checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
110dependencies = [
111 "unicode-ident",
112]
113
114[[package]]
115name = "quote"
116version = "1.0.45"
117source = "registry+https://github.com/rust-lang/crates.io-index"
118checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
119dependencies = [
120 "proc-macro2",
121]
122
123[[package]]
124name = "serde"
125version = "1.0.228"
126source = "registry+https://github.com/rust-lang/crates.io-index"
127checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
128dependencies = [
129 "serde_core",
130 "serde_derive",
131]
132
133[[package]]
134name = "serde_core"
135version = "1.0.228"
136source = "registry+https://github.com/rust-lang/crates.io-index"
137checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
138dependencies = [
139 "serde_derive",
140]
141
142[[package]]
143name = "serde_derive"
144version = "1.0.228"
145source = "registry+https://github.com/rust-lang/crates.io-index"
146checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
147dependencies = [
148 "proc-macro2",
149 "quote",
150 "syn",
151]
152
153[[package]]
154name = "serde_json"
155version = "1.0.150"
156source = "registry+https://github.com/rust-lang/crates.io-index"
157checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
158dependencies = [
159 "itoa",
160 "memchr",
161 "serde",
162 "serde_core",
163 "zmij",
164]
165
166[[package]]
167name = "syn"
168version = "2.0.118"
169source = "registry+https://github.com/rust-lang/crates.io-index"
170checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
171dependencies = [
172 "proc-macro2",
173 "quote",
174 "unicode-ident",
175]
176
177[[package]]
178name = "sysinfo"
179version = "0.39.3"
180source = "registry+https://github.com/rust-lang/crates.io-index"
181checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96"
182dependencies = [
183 "libc",
184 "memchr",
185 "ntapi",
186 "objc2-core-foundation",
187 "objc2-io-kit",
188 "objc2-open-directory",
189 "windows",
190]
191
192[[package]]
193name = "t3-resource-monitor"
194version = "0.1.0"
195dependencies = [
196 "serde",
197 "serde_json",
198 "sysinfo",
199]
200
201[[package]]
202name = "unicode-ident"
203version = "1.0.24"
204source = "registry+https://github.com/rust-lang/crates.io-index"
205checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
206
207[[package]]
208name = "winapi"
209version = "0.3.9"
210source = "registry+https://github.com/rust-lang/crates.io-index"
211checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
212dependencies = [
213 "winapi-i686-pc-windows-gnu",
214 "winapi-x86_64-pc-windows-gnu",
215]
216
217[[package]]
218name = "winapi-i686-pc-windows-gnu"
219version = "0.4.0"
220source = "registry+https://github.com/rust-lang/crates.io-index"
221checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
222
223[[package]]
224name = "winapi-x86_64-pc-windows-gnu"
225version = "0.4.0"
226source = "registry+https://github.com/rust-lang/crates.io-index"
227checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
228
229[[package]]
230name = "windows"
231version = "0.62.2"
232source = "registry+https://github.com/rust-lang/crates.io-index"
233checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
234dependencies = [
235 "windows-collections",
236 "windows-core",
237 "windows-future",
238 "windows-numerics",
239]
240
241[[package]]
242name = "windows-collections"
243version = "0.3.2"
244source = "registry+https://github.com/rust-lang/crates.io-index"
245checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
246dependencies = [
247 "windows-core",
248]
249
250[[package]]
251name = "windows-core"
252version = "0.62.2"
253source = "registry+https://github.com/rust-lang/crates.io-index"
254checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
255dependencies = [
256 "windows-implement",
257 "windows-interface",
258 "windows-link",
259 "windows-result",
260 "windows-strings",
261]
262
263[[package]]
264name = "windows-future"
265version = "0.3.2"
266source = "registry+https://github.com/rust-lang/crates.io-index"
267checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
268dependencies = [
269 "windows-core",
270 "windows-link",
271 "windows-threading",
272]
273
274[[package]]
275name = "windows-implement"
276version = "0.60.2"
277source = "registry+https://github.com/rust-lang/crates.io-index"
278checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
279dependencies = [
280 "proc-macro2",
281 "quote",
282 "syn",
283]
284
285[[package]]
286name = "windows-interface"
287version = "0.59.3"
288source = "registry+https://github.com/rust-lang/crates.io-index"
289checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
290dependencies = [
291 "proc-macro2",
292 "quote",
293 "syn",
294]
295
296[[package]]
297name = "windows-link"
298version = "0.2.1"
299source = "registry+https://github.com/rust-lang/crates.io-index"
300checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
301
302[[package]]
303name = "windows-numerics"
304version = "0.3.1"
305source = "registry+https://github.com/rust-lang/crates.io-index"
306checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
307dependencies = [
308 "windows-core",
309 "windows-link",
310]
311
312[[package]]
313name = "windows-result"
314version = "0.4.1"
315source = "registry+https://github.com/rust-lang/crates.io-index"
316checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
317dependencies = [
318 "windows-link",
319]
320
321[[package]]
322name = "windows-strings"
323version = "0.5.1"
324source = "registry+https://github.com/rust-lang/crates.io-index"
325checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
326dependencies = [
327 "windows-link",
328]
329
330[[package]]
331name = "windows-threading"
332version = "0.2.1"
333source = "registry+https://github.com/rust-lang/crates.io-index"
334checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
335dependencies = [
336 "windows-link",
337]
338
339[[package]]
340name = "zmij"
341version = "1.0.21"
342source = "registry+https://github.com/rust-lang/crates.io-index"
343checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
native/resource-monitor/Cargo.toml created+17
......@@ -0,0 +1,17 @@
1[package]
2name = "t3-resource-monitor"
3version = "0.1.0"
4edition = "2024"
5license = "MIT"
6publish = false
7
8[dependencies]
9serde = { version = "1.0.228", features = ["derive"] }
10serde_json = "1.0.150"
11sysinfo = "0.39.3"
12
13[profile.release]
14codegen-units = 1
15lto = "thin"
16panic = "abort"
17strip = true
native/resource-monitor/src/main.rs created+1160
......@@ -0,0 +1,1160 @@
1use serde::{Deserialize, Serialize};
2use std::collections::{HashMap, HashSet, VecDeque};
3use std::io::{self, BufRead, BufWriter, Write};
4use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
5use std::thread;
6use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7use sysinfo::{
8 MINIMUM_CPU_UPDATE_INTERVAL, Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind,
9};
10
11const PROTOCOL_VERSION: u32 = 2;
12const MIN_SAMPLE_INTERVAL_MS: u64 = 250;
13const MAX_SAMPLE_INTERVAL_MS: u64 = 60_000;
14const PROCESS_START_TIME_PRECISION_MS: u64 = 1_000;
15const HISTORY_RETENTION_MS: u64 = 60 * 60_000;
16const MAX_HISTORY_SNAPSHOTS: usize = 3_600;
17const INPUT_QUEUE_CAPACITY: usize = 64;
18const MAX_HISTORY_RETAINED_ENTRIES: usize = 20_000;
19const MAX_HISTORY_RETAINED_BYTES: usize = 64 * 1024 * 1024;
20const MAX_PROCESS_NAME_BYTES: usize = 1_024;
21const MAX_PROCESS_COMMAND_BYTES: usize = 16 * 1_024;
22const MAX_PROCESS_STATUS_BYTES: usize = 256;
23const HISTORY_CHUNK_SNAPSHOTS: usize = 32;
24
25#[derive(Debug, Clone, Deserialize, Serialize)]
26#[serde(rename_all = "camelCase")]
27struct ExternalProcess {
28 pid: u32,
29 #[serde(default)]
30 start_time_ms: Option<u64>,
31}
32
33impl ExternalProcess {
34 fn estimated_history_bytes(&self) -> usize {
35 std::mem::size_of::<Self>()
36 }
37}
38
39#[derive(Debug, Deserialize)]
40#[serde(
41 tag = "type",
42 rename_all = "camelCase",
43 rename_all_fields = "camelCase"
44)]
45enum Command {
46 Configure {
47 version: u32,
48 root_pid: u32,
49 sample_interval_ms: u64,
50 #[serde(default)]
51 external_processes: Vec<ExternalProcess>,
52 },
53 SetExternalProcesses {
54 version: u32,
55 processes: Vec<ExternalProcess>,
56 },
57 SetSampleInterval {
58 version: u32,
59 sample_interval_ms: u64,
60 },
61 SetStreaming {
62 version: u32,
63 enabled: bool,
64 },
65 SampleNow {
66 version: u32,
67 request_id: String,
68 },
69 ReadHistory {
70 version: u32,
71 request_id: String,
72 window_ms: u64,
73 },
74 Shutdown {
75 version: u32,
76 },
77}
78
79impl Command {
80 fn version(&self) -> u32 {
81 match self {
82 Self::Configure { version, .. }
83 | Self::SetExternalProcesses { version, .. }
84 | Self::SetSampleInterval { version, .. }
85 | Self::SetStreaming { version, .. }
86 | Self::SampleNow { version, .. }
87 | Self::ReadHistory { version, .. }
88 | Self::Shutdown { version } => *version,
89 }
90 }
91}
92
93enum Input {
94 Command(Command),
95 Invalid(String),
96}
97
98#[derive(Debug, Serialize)]
99#[serde(rename_all = "camelCase")]
100struct Capabilities {
101 cumulative_cpu_time: bool,
102 current_cpu_percent: bool,
103 resident_memory: bool,
104 virtual_memory: bool,
105 io_bytes: bool,
106 process_start_time: bool,
107 process_tree: bool,
108}
109
110#[derive(Debug, Serialize)]
111#[serde(rename_all = "camelCase")]
112struct HelloEvent {
113 version: u32,
114 #[serde(rename = "type")]
115 event_type: &'static str,
116 sidecar_version: &'static str,
117 sidecar_pid: u32,
118 platform: &'static str,
119 arch: &'static str,
120 capabilities: Capabilities,
121}
122
123#[derive(Debug, Clone, Copy, Serialize)]
124#[serde(rename_all = "kebab-case")]
125enum IoSemantics {
126 Storage,
127 AllIo,
128}
129
130#[derive(Debug, Clone, Serialize)]
131#[serde(rename_all = "camelCase")]
132struct ProcessSample {
133 pid: u32,
134 ppid: u32,
135 start_time_ms: u64,
136 run_time_ms: u64,
137 name: String,
138 command: String,
139 status: String,
140 cpu_percent: f32,
141 cpu_time_ms: u64,
142 resident_bytes: u64,
143 virtual_bytes: u64,
144 io_read_bytes: u64,
145 io_write_bytes: u64,
146 io_semantics: IoSemantics,
147}
148
149impl ProcessSample {
150 fn estimated_history_bytes(&self) -> usize {
151 std::mem::size_of::<Self>()
152 .saturating_add(self.name.len())
153 .saturating_add(self.command.len())
154 .saturating_add(self.status.len())
155 }
156}
157
158#[derive(Debug, Clone, Serialize)]
159#[serde(rename_all = "camelCase")]
160struct SnapshotEvent {
161 version: u32,
162 #[serde(rename = "type")]
163 event_type: &'static str,
164 sequence: u64,
165 sampled_at_unix_ms: u64,
166 collection_duration_micros: u64,
167 scanned_process_count: usize,
168 retained_process_count: usize,
169 inaccessible_process_count: usize,
170 #[serde(skip_serializing_if = "Option::is_none")]
171 request_id: Option<String>,
172 external_processes: Vec<ExternalProcess>,
173 processes: Vec<ProcessSample>,
174}
175
176impl SnapshotEvent {
177 fn retained_entry_count(&self) -> usize {
178 self.processes
179 .len()
180 .saturating_add(self.external_processes.len())
181 }
182
183 fn estimated_history_bytes(&self) -> usize {
184 std::mem::size_of::<Self>()
185 .saturating_add(
186 self.processes
187 .iter()
188 .map(ProcessSample::estimated_history_bytes)
189 .sum::<usize>(),
190 )
191 .saturating_add(
192 self.external_processes
193 .iter()
194 .map(ExternalProcess::estimated_history_bytes)
195 .sum::<usize>(),
196 )
197 }
198}
199
200#[derive(Debug, Serialize)]
201#[serde(rename_all = "camelCase")]
202struct HistoryChunkEvent<'a> {
203 version: u32,
204 #[serde(rename = "type")]
205 event_type: &'static str,
206 request_id: &'a str,
207 done: bool,
208 snapshots: &'a [SnapshotEvent],
209}
210
211#[derive(Debug, Serialize)]
212#[serde(rename_all = "camelCase")]
213struct ErrorEvent {
214 version: u32,
215 #[serde(rename = "type")]
216 event_type: &'static str,
217 code: &'static str,
218 message: String,
219 recoverable: bool,
220}
221
222#[derive(Debug, Clone)]
223struct CollectorConfig {
224 root_pid: u32,
225 sample_interval: Option<Duration>,
226 external_processes: HashMap<u32, Option<u64>>,
227}
228
229#[derive(Default)]
230struct HistoryRecorder {
231 snapshots: VecDeque<SnapshotEvent>,
232 retained_entry_count: usize,
233 retained_bytes: usize,
234}
235
236impl HistoryRecorder {
237 fn record(&mut self, snapshot: &SnapshotEvent) {
238 self.record_with_limits(
239 snapshot,
240 MAX_HISTORY_SNAPSHOTS,
241 MAX_HISTORY_RETAINED_ENTRIES,
242 MAX_HISTORY_RETAINED_BYTES,
243 );
244 }
245
246 fn record_with_limits(
247 &mut self,
248 snapshot: &SnapshotEvent,
249 max_snapshots: usize,
250 max_retained_entries: usize,
251 max_retained_bytes: usize,
252 ) {
253 let mut retained = snapshot.clone();
254 retained.request_id = None;
255 self.retained_entry_count = self
256 .retained_entry_count
257 .saturating_add(retained.retained_entry_count());
258 self.retained_bytes = self
259 .retained_bytes
260 .saturating_add(retained.estimated_history_bytes());
261 self.snapshots.push_back(retained);
262 self.trim_to_limits(
263 snapshot.sampled_at_unix_ms,
264 max_snapshots,
265 max_retained_entries,
266 max_retained_bytes,
267 );
268 }
269
270 fn trim_to_limits(
271 &mut self,
272 now_ms: u64,
273 max_snapshots: usize,
274 max_retained_entries: usize,
275 max_retained_bytes: usize,
276 ) {
277 let mut future_entry_count = 0usize;
278 let mut future_bytes = 0usize;
279 self.snapshots.retain(|snapshot| {
280 let keep = snapshot.sampled_at_unix_ms <= now_ms;
281 if !keep {
282 future_entry_count =
283 future_entry_count.saturating_add(snapshot.retained_entry_count());
284 future_bytes = future_bytes.saturating_add(snapshot.estimated_history_bytes());
285 }
286 keep
287 });
288 self.retained_entry_count = self.retained_entry_count.saturating_sub(future_entry_count);
289 self.retained_bytes = self.retained_bytes.saturating_sub(future_bytes);
290
291 while self.snapshots.front().is_some_and(|snapshot| {
292 snapshot.sampled_at_unix_ms < now_ms.saturating_sub(HISTORY_RETENTION_MS)
293 || self.snapshots.len() > max_snapshots
294 || self.retained_entry_count > max_retained_entries
295 || self.retained_bytes > max_retained_bytes
296 }) {
297 if let Some(removed) = self.snapshots.pop_front() {
298 self.retained_entry_count = self
299 .retained_entry_count
300 .saturating_sub(removed.retained_entry_count());
301 self.retained_bytes = self
302 .retained_bytes
303 .saturating_sub(removed.estimated_history_bytes());
304 }
305 }
306 }
307
308 fn read(&self, window_ms: u64, now_ms: u64) -> Vec<SnapshotEvent> {
309 let started_at_ms = now_ms.saturating_sub(window_ms.min(HISTORY_RETENTION_MS));
310 self.snapshots
311 .iter()
312 .filter(|snapshot| {
313 snapshot.sampled_at_unix_ms >= started_at_ms
314 && snapshot.sampled_at_unix_ms <= now_ms
315 })
316 .cloned()
317 .collect()
318 }
319}
320
321struct Collector {
322 system: System,
323 sequence: u64,
324 cpu_baseline_refreshed_at: Option<Instant>,
325}
326
327impl Collector {
328 fn new() -> Self {
329 Self {
330 system: System::new(),
331 sequence: 0,
332 cpu_baseline_refreshed_at: None,
333 }
334 }
335
336 fn prime_cpu_usage(&mut self) {
337 self.system.refresh_processes_specifics(
338 ProcessesToUpdate::All,
339 true,
340 process_refresh_kind(),
341 );
342 self.cpu_baseline_refreshed_at = Some(Instant::now());
343 }
344
345 fn sample(&mut self, config: &CollectorConfig, request_id: Option<String>) -> SnapshotEvent {
346 if let Some(delay) =
347 remaining_cpu_measurement_delay(self.cpu_baseline_refreshed_at.take(), Instant::now())
348 {
349 thread::sleep(delay);
350 }
351 let collection_started = Instant::now();
352 self.system.refresh_processes_specifics(
353 ProcessesToUpdate::All,
354 true,
355 process_refresh_kind(),
356 );
357 self.cpu_baseline_refreshed_at = Some(Instant::now());
358
359 let rows = self
360 .system
361 .processes()
362 .iter()
363 .map(|(pid, process)| {
364 let pid = pid.as_u32();
365 let ppid = process.parent().map(Pid::as_u32).unwrap_or(0);
366 (pid, ppid, process.start_time().saturating_mul(1_000))
367 })
368 .collect::<Vec<_>>();
369 let external_processes = config
370 .external_processes
371 .iter()
372 .filter_map(|(pid, expected_start_time_ms)| {
373 let (_, _, actual_start_time_ms) = rows
374 .iter()
375 .find(|(candidate_pid, _, _)| candidate_pid == pid)?;
376 matches_external_identity(*actual_start_time_ms, *expected_start_time_ms).then_some(
377 ExternalProcess {
378 pid: *pid,
379 start_time_ms: Some(*actual_start_time_ms),
380 },
381 )
382 })
383 .collect::<Vec<_>>();
384 let mut roots = external_processes
385 .iter()
386 .map(|process| process.pid)
387 .collect::<HashSet<_>>();
388 roots.insert(config.root_pid);
389 let tracked = select_tracked_pids(&rows, &roots);
390 let tracked_process_count = tracked.len();
391 let mut processes = tracked
392 .into_iter()
393 .filter_map(|pid| {
394 let process = self.system.process(Pid::from_u32(pid))?;
395 let disk_usage = process.disk_usage();
396 let command = if process.cmd().is_empty() {
397 process.name().to_string_lossy().into_owned()
398 } else {
399 process
400 .cmd()
401 .iter()
402 .map(|part| part.to_string_lossy())
403 .collect::<Vec<_>>()
404 .join(" ")
405 };
406
407 Some(ProcessSample {
408 pid,
409 ppid: process.parent().map(Pid::as_u32).unwrap_or(0),
410 start_time_ms: process.start_time().saturating_mul(1_000),
411 run_time_ms: process.run_time().saturating_mul(1_000),
412 name: truncate_utf8(
413 process.name().to_string_lossy().into_owned(),
414 MAX_PROCESS_NAME_BYTES,
415 ),
416 command: truncate_utf8(command, MAX_PROCESS_COMMAND_BYTES),
417 status: truncate_utf8(
418 format!("{:?}", process.status()),
419 MAX_PROCESS_STATUS_BYTES,
420 ),
421 cpu_percent: process.cpu_usage(),
422 cpu_time_ms: process.accumulated_cpu_time(),
423 resident_bytes: process.memory(),
424 virtual_bytes: process.virtual_memory(),
425 io_read_bytes: disk_usage.total_read_bytes,
426 io_write_bytes: disk_usage.total_written_bytes,
427 io_semantics: io_semantics(),
428 })
429 })
430 .collect::<Vec<_>>();
431 processes.sort_by_key(|process| process.pid);
432 self.sequence = self.sequence.saturating_add(1);
433
434 SnapshotEvent {
435 version: PROTOCOL_VERSION,
436 event_type: "snapshot",
437 sequence: self.sequence,
438 sampled_at_unix_ms: unix_time_ms(),
439 collection_duration_micros: collection_started.elapsed().as_micros() as u64,
440 scanned_process_count: self.system.processes().len(),
441 retained_process_count: processes.len(),
442 inaccessible_process_count: inaccessible_process_count(
443 tracked_process_count,
444 processes.len(),
445 ),
446 request_id,
447 external_processes,
448 processes,
449 }
450 }
451}
452
453fn process_refresh_kind() -> ProcessRefreshKind {
454 ProcessRefreshKind::nothing()
455 .with_memory()
456 .with_cpu()
457 .with_disk_usage()
458 .with_cmd(UpdateKind::Always)
459 .without_tasks()
460}
461
462fn inaccessible_process_count(selected: usize, materialized: usize) -> usize {
463 selected.saturating_sub(materialized)
464}
465
466fn remaining_cpu_measurement_delay(
467 baseline_refreshed_at: Option<Instant>,
468 now: Instant,
469) -> Option<Duration> {
470 baseline_refreshed_at
471 .and_then(|baseline| MINIMUM_CPU_UPDATE_INTERVAL.checked_sub(now.duration_since(baseline)))
472 .filter(|delay| !delay.is_zero())
473}
474
475fn matches_external_identity(
476 actual_start_time_ms: u64,
477 expected_start_time_ms: Option<u64>,
478) -> bool {
479 // sysinfo reports process starts at whole-second precision. Normalize the
480 // higher-resolution Electron timestamp to that same bucket instead of
481 // accepting adjacent seconds, which could attach a quickly reused PID.
482 expected_start_time_ms.is_none_or(|expected| {
483 actual_start_time_ms == expected - (expected % PROCESS_START_TIME_PRECISION_MS)
484 })
485}
486
487fn select_tracked_pids(rows: &[(u32, u32, u64)], roots: &HashSet<u32>) -> HashSet<u32> {
488 let mut children_by_parent = HashMap::<u32, Vec<(u32, u64)>>::new();
489 let mut start_time_by_pid = HashMap::<u32, u64>::new();
490 for (pid, ppid, start_time_ms) in rows {
491 children_by_parent
492 .entry(*ppid)
493 .or_default()
494 .push((*pid, *start_time_ms));
495 start_time_by_pid.insert(*pid, *start_time_ms);
496 }
497
498 let mut tracked = HashSet::new();
499 let mut visited_identities = HashSet::new();
500 let mut queue = roots
501 .iter()
502 .filter_map(|pid| {
503 start_time_by_pid
504 .get(pid)
505 .map(|start_time_ms| (*pid, *start_time_ms))
506 })
507 .collect::<VecDeque<_>>();
508
509 while let Some((pid, start_time_ms)) = queue.pop_front() {
510 if !visited_identities.insert((pid, start_time_ms)) {
511 continue;
512 }
513 tracked.insert(pid);
514 if let Some(children) = children_by_parent.get(&pid) {
515 queue.extend(
516 children
517 .iter()
518 .copied()
519 .filter(|(_, child_start_time_ms)| *child_start_time_ms >= start_time_ms),
520 );
521 }
522 }
523
524 tracked
525}
526
527fn truncate_utf8(mut value: String, max_bytes: usize) -> String {
528 if value.len() <= max_bytes {
529 return value;
530 }
531 let mut boundary = max_bytes;
532 while !value.is_char_boundary(boundary) {
533 boundary = boundary.saturating_sub(1);
534 }
535 value.truncate(boundary);
536 value
537}
538
539fn io_semantics() -> IoSemantics {
540 if cfg!(target_os = "windows") {
541 IoSemantics::AllIo
542 } else {
543 IoSemantics::Storage
544 }
545}
546
547fn unix_time_ms() -> u64 {
548 SystemTime::now()
549 .duration_since(UNIX_EPOCH)
550 .unwrap_or_default()
551 .as_millis() as u64
552}
553
554fn clamp_sample_interval(sample_interval_ms: u64) -> Option<Duration> {
555 (sample_interval_ms > 0).then(|| {
556 Duration::from_millis(
557 sample_interval_ms.clamp(MIN_SAMPLE_INTERVAL_MS, MAX_SAMPLE_INTERVAL_MS),
558 )
559 })
560}
561
562fn spawn_input_reader() -> Receiver<Input> {
563 let (sender, receiver) = mpsc::sync_channel(INPUT_QUEUE_CAPACITY);
564 thread::spawn(move || {
565 let stdin = io::stdin();
566 for line in stdin.lock().lines() {
567 let line = match line {
568 Ok(line) => line,
569 Err(error) => {
570 let _ = sender.send(Input::Invalid(format!(
571 "failed reading command stream: {error}"
572 )));
573 return;
574 }
575 };
576 if line.trim().is_empty() {
577 continue;
578 }
579 match serde_json::from_str::<Command>(&line) {
580 Ok(command) => {
581 if sender.send(Input::Command(command)).is_err() {
582 return;
583 }
584 }
585 Err(error) => {
586 if sender
587 .send(Input::Invalid(format!("invalid command: {error}")))
588 .is_err()
589 {
590 return;
591 }
592 }
593 }
594 }
595 });
596 receiver
597}
598
599fn sample_now_deadline(
600 current: Option<Instant>,
601 interval: Option<Duration>,
602 now: Instant,
603) -> Option<Instant> {
604 current.or_else(|| interval.map(|duration| now + duration))
605}
606
607fn write_event<T: Serialize>(writer: &mut impl Write, event: &T) -> io::Result<()> {
608 serde_json::to_writer(&mut *writer, event)?;
609 writer.write_all(b"\n")?;
610 writer.flush()
611}
612
613fn write_error(
614 writer: &mut impl Write,
615 code: &'static str,
616 message: impl Into<String>,
617 recoverable: bool,
618) -> io::Result<()> {
619 write_event(
620 writer,
621 &ErrorEvent {
622 version: PROTOCOL_VERSION,
623 event_type: "error",
624 code,
625 message: message.into(),
626 recoverable,
627 },
628 )
629}
630
631fn write_history(
632 writer: &mut impl Write,
633 request_id: &str,
634 snapshots: &[SnapshotEvent],
635) -> io::Result<()> {
636 if snapshots.is_empty() {
637 return write_event(
638 writer,
639 &HistoryChunkEvent {
640 version: PROTOCOL_VERSION,
641 event_type: "historyChunk",
642 request_id,
643 done: true,
644 snapshots,
645 },
646 );
647 }
648
649 let chunk_count = snapshots.len().div_ceil(HISTORY_CHUNK_SNAPSHOTS);
650 for (index, chunk) in snapshots.chunks(HISTORY_CHUNK_SNAPSHOTS).enumerate() {
651 write_event(
652 writer,
653 &HistoryChunkEvent {
654 version: PROTOCOL_VERSION,
655 event_type: "historyChunk",
656 request_id,
657 done: index + 1 == chunk_count,
658 snapshots: chunk,
659 },
660 )?;
661 }
662 Ok(())
663}
664
665fn main() -> io::Result<()> {
666 let mut writer = BufWriter::new(io::stdout().lock());
667 write_event(
668 &mut writer,
669 &HelloEvent {
670 version: PROTOCOL_VERSION,
671 event_type: "hello",
672 sidecar_version: env!("CARGO_PKG_VERSION"),
673 sidecar_pid: std::process::id(),
674 platform: std::env::consts::OS,
675 arch: std::env::consts::ARCH,
676 capabilities: Capabilities {
677 cumulative_cpu_time: true,
678 current_cpu_percent: true,
679 resident_memory: true,
680 virtual_memory: true,
681 io_bytes: true,
682 process_start_time: true,
683 process_tree: true,
684 },
685 },
686 )?;
687
688 let receiver = spawn_input_reader();
689 let mut collector = Collector::new();
690 let mut history = HistoryRecorder::default();
691 let mut config: Option<CollectorConfig> = None;
692 let mut next_sample_at: Option<Instant> = None;
693 let mut streaming_enabled = false;
694
695 loop {
696 if next_sample_at.is_some_and(|deadline| deadline <= Instant::now()) {
697 if let Some(current) = config.as_ref() {
698 if let Some(interval) = current.sample_interval {
699 let event = collector.sample(current, None);
700 history.record(&event);
701 if streaming_enabled {
702 write_event(&mut writer, &event)?;
703 }
704 next_sample_at = Some(Instant::now() + interval);
705 } else {
706 next_sample_at = None;
707 }
708 } else {
709 next_sample_at = None;
710 }
711 continue;
712 }
713
714 let timeout = next_sample_at
715 .map(|deadline| deadline.saturating_duration_since(Instant::now()))
716 .unwrap_or(Duration::from_secs(60));
717
718 match receiver.recv_timeout(timeout) {
719 Ok(Input::Invalid(message)) => {
720 write_error(&mut writer, "invalid-command", message, true)?;
721 }
722 Ok(Input::Command(command)) => {
723 if command.version() != PROTOCOL_VERSION {
724 write_error(
725 &mut writer,
726 "protocol-mismatch",
727 format!(
728 "unsupported protocol version {}; expected {PROTOCOL_VERSION}",
729 command.version()
730 ),
731 false,
732 )?;
733 continue;
734 }
735
736 match command {
737 Command::Configure {
738 root_pid,
739 sample_interval_ms,
740 external_processes,
741 ..
742 } => {
743 let sample_interval = clamp_sample_interval(sample_interval_ms);
744 config = Some(CollectorConfig {
745 root_pid,
746 sample_interval,
747 external_processes: external_processes
748 .into_iter()
749 .map(|process| (process.pid, process.start_time_ms))
750 .collect(),
751 });
752 collector.prime_cpu_usage();
753 next_sample_at = sample_interval.map(|_| Instant::now());
754 }
755 Command::SetExternalProcesses { processes, .. } => {
756 if let Some(current) = config.as_mut() {
757 current.external_processes = processes
758 .into_iter()
759 .map(|process| (process.pid, process.start_time_ms))
760 .collect();
761 } else {
762 write_error(
763 &mut writer,
764 "not-configured",
765 "configure must be sent before external processes",
766 true,
767 )?;
768 }
769 }
770 Command::SetSampleInterval {
771 sample_interval_ms, ..
772 } => {
773 if let Some(current) = config.as_mut() {
774 current.sample_interval = clamp_sample_interval(sample_interval_ms);
775 next_sample_at = current
776 .sample_interval
777 .map(|interval| Instant::now() + interval);
778 } else {
779 write_error(
780 &mut writer,
781 "not-configured",
782 "configure must be sent before changing the sample interval",
783 true,
784 )?;
785 }
786 }
787 Command::SetStreaming { enabled, .. } => {
788 streaming_enabled = enabled;
789 }
790 Command::SampleNow { request_id, .. } => {
791 if let Some(current) = config.as_ref() {
792 let event = collector.sample(current, Some(request_id));
793 history.record(&event);
794 write_event(&mut writer, &event)?;
795 next_sample_at = sample_now_deadline(
796 next_sample_at,
797 current.sample_interval,
798 Instant::now(),
799 );
800 } else {
801 write_error(
802 &mut writer,
803 "not-configured",
804 "configure must be sent before sampling",
805 true,
806 )?;
807 }
808 }
809 Command::ReadHistory {
810 request_id,
811 window_ms,
812 ..
813 } => {
814 if config.is_some() {
815 let snapshots = history.read(window_ms, unix_time_ms());
816 write_history(&mut writer, &request_id, &snapshots)?;
817 } else {
818 write_error(
819 &mut writer,
820 "not-configured",
821 "configure must be sent before reading history",
822 true,
823 )?;
824 }
825 }
826 Command::Shutdown { .. } => return Ok(()),
827 }
828 }
829 Err(RecvTimeoutError::Timeout) => {}
830 Err(RecvTimeoutError::Disconnected) => return Ok(()),
831 }
832 }
833}
834
835#[cfg(test)]
836mod tests {
837 use super::*;
838
839 #[test]
840 fn selects_roots_and_all_descendants() {
841 let rows = vec![
842 (10, 1, 1_000),
843 (11, 10, 1_100),
844 (12, 11, 1_200),
845 (20, 1, 2_000),
846 (21, 20, 2_100),
847 (30, 99, 3_000),
848 ];
849 let tracked = select_tracked_pids(&rows, &HashSet::from([10, 20]));
850
851 assert_eq!(tracked, HashSet::from([10, 11, 12, 20, 21]));
852 }
853
854 #[test]
855 fn rejects_descendants_older_than_a_reused_parent_pid() {
856 let rows = vec![
857 (20, 1, 5_000),
858 (21, 20, 4_000),
859 (22, 20, 5_100),
860 (23, 21, 5_200),
861 ];
862 let tracked = select_tracked_pids(&rows, &HashSet::from([20]));
863
864 assert_eq!(tracked, HashSet::from([20, 22]));
865 }
866
867 #[test]
868 fn ignores_missing_roots() {
869 let rows = vec![(10, 1, 1_000), (11, 10, 1_100)];
870 let tracked = select_tracked_pids(&rows, &HashSet::from([99]));
871
872 assert!(tracked.is_empty());
873 }
874
875 #[test]
876 fn validates_external_process_start_identity() {
877 assert!(matches_external_identity(10_000, None));
878 assert!(matches_external_identity(10_000, Some(10_999)));
879 assert!(!matches_external_identity(10_000, Some(11_000)));
880 assert!(!matches_external_identity(10_000, Some(9_999)));
881 }
882
883 #[test]
884 fn decodes_protocol_commands() {
885 let configure = serde_json::from_str::<Command>(
886 r#"{"version":2,"type":"configure","rootPid":42,"sampleIntervalMs":1000,"externalProcesses":[{"pid":7}]}"#,
887 )
888 .expect("configure command");
889
890 match configure {
891 Command::Configure {
892 root_pid,
893 sample_interval_ms,
894 external_processes,
895 ..
896 } => {
897 assert_eq!(root_pid, 42);
898 assert_eq!(sample_interval_ms, 1_000);
899 assert_eq!(external_processes[0].pid, 7);
900 assert_eq!(external_processes[0].start_time_ms, None);
901 }
902 _ => panic!("unexpected command"),
903 }
904
905 let read_history = serde_json::from_str::<Command>(
906 r#"{"version":2,"type":"readHistory","requestId":"history-1","windowMs":60000}"#,
907 )
908 .expect("read history command");
909 assert!(matches!(
910 read_history,
911 Command::ReadHistory {
912 request_id,
913 window_ms: 60_000,
914 ..
915 } if request_id == "history-1"
916 ));
917 }
918
919 #[test]
920 fn clamps_sample_interval() {
921 assert_eq!(clamp_sample_interval(0), None);
922 assert_eq!(clamp_sample_interval(1), Some(Duration::from_millis(250)));
923 assert_eq!(
924 clamp_sample_interval(100_000),
925 Some(Duration::from_millis(60_000))
926 );
927 }
928
929 #[test]
930 fn counts_selected_processes_that_could_not_be_materialized() {
931 assert_eq!(inaccessible_process_count(5, 3), 2);
932 assert_eq!(inaccessible_process_count(3, 5), 0);
933 }
934
935 #[test]
936 fn waits_for_a_cpu_measurement_window_after_priming() {
937 let baseline = Instant::now();
938
939 assert_eq!(
940 remaining_cpu_measurement_delay(Some(baseline), baseline),
941 Some(MINIMUM_CPU_UPDATE_INTERVAL)
942 );
943 assert_eq!(
944 remaining_cpu_measurement_delay(Some(baseline), baseline + MINIMUM_CPU_UPDATE_INTERVAL),
945 None
946 );
947 assert_eq!(remaining_cpu_measurement_delay(None, baseline), None);
948 }
949
950 #[test]
951 fn retains_bounded_history_without_request_ids() {
952 let mut history = HistoryRecorder::default();
953 for sequence in 0..=MAX_HISTORY_SNAPSHOTS {
954 history.record(&SnapshotEvent {
955 version: PROTOCOL_VERSION,
956 event_type: "snapshot",
957 sequence: sequence as u64,
958 sampled_at_unix_ms: sequence as u64 * 1_000,
959 collection_duration_micros: 1,
960 scanned_process_count: 0,
961 retained_process_count: 0,
962 inaccessible_process_count: 0,
963 request_id: Some("request".to_owned()),
964 external_processes: vec![ExternalProcess {
965 pid: 7,
966 start_time_ms: Some(1_000),
967 }],
968 processes: Vec::new(),
969 });
970 }
971
972 assert_eq!(history.snapshots.len(), MAX_HISTORY_SNAPSHOTS);
973 assert!(
974 history
975 .snapshots
976 .iter()
977 .all(|snapshot| snapshot.request_id.is_none())
978 );
979 assert!(history.snapshots.iter().all(|snapshot| {
980 snapshot.external_processes.len() == 1
981 && snapshot.external_processes[0].pid == 7
982 && snapshot.external_processes[0].start_time_ms == Some(1_000)
983 }));
984 assert_eq!(
985 history
986 .read(10_000, MAX_HISTORY_SNAPSHOTS as u64 * 1_000)
987 .len(),
988 11
989 );
990 }
991
992 #[test]
993 fn excludes_and_trims_future_history_after_the_clock_moves_backward() {
994 let mut history = HistoryRecorder::default();
995 let snapshot = SnapshotEvent {
996 version: PROTOCOL_VERSION,
997 event_type: "snapshot",
998 sequence: 1,
999 sampled_at_unix_ms: 2_000,
1000 collection_duration_micros: 1,
1001 scanned_process_count: 0,
1002 retained_process_count: 0,
1003 inaccessible_process_count: 0,
1004 request_id: None,
1005 external_processes: Vec::new(),
1006 processes: Vec::new(),
1007 };
1008 history.record(&snapshot);
1009
1010 assert!(history.read(0, 1_000).is_empty());
1011
1012 history.record(&SnapshotEvent {
1013 sequence: 2,
1014 sampled_at_unix_ms: 1_000,
1015 ..snapshot
1016 });
1017 assert_eq!(history.snapshots.len(), 1);
1018 assert_eq!(
1019 history.snapshots.front().map(|entry| entry.sequence),
1020 Some(2)
1021 );
1022 }
1023
1024 #[test]
1025 fn bounds_history_by_estimated_process_bytes() {
1026 let mut history = HistoryRecorder::default();
1027 let command = "x".repeat(128);
1028 let process = ProcessSample {
1029 pid: 1,
1030 ppid: 0,
1031 start_time_ms: 0,
1032 run_time_ms: 0,
1033 name: "process".to_owned(),
1034 command,
1035 status: "Run".to_owned(),
1036 cpu_percent: 0.0,
1037 cpu_time_ms: 0,
1038 resident_bytes: 0,
1039 virtual_bytes: 0,
1040 io_read_bytes: 0,
1041 io_write_bytes: 0,
1042 io_semantics: IoSemantics::Storage,
1043 };
1044 let snapshot_bytes =
1045 std::mem::size_of::<SnapshotEvent>() + process.estimated_history_bytes();
1046 for sequence in 0..3 {
1047 history.record_with_limits(
1048 &SnapshotEvent {
1049 version: PROTOCOL_VERSION,
1050 event_type: "snapshot",
1051 sequence,
1052 sampled_at_unix_ms: sequence * 1_000,
1053 collection_duration_micros: 1,
1054 scanned_process_count: 1,
1055 retained_process_count: 1,
1056 inaccessible_process_count: 0,
1057 request_id: None,
1058 external_processes: Vec::new(),
1059 processes: vec![ProcessSample {
1060 pid: sequence as u32 + 1,
1061 start_time_ms: sequence * 1_000,
1062 ..process.clone()
1063 }],
1064 },
1065 3,
1066 3,
1067 snapshot_bytes * 2,
1068 );
1069 }
1070
1071 assert!(history.retained_bytes <= snapshot_bytes * 2);
1072 assert_eq!(history.snapshots.len(), 2);
1073 assert_eq!(
1074 history.snapshots.front().map(|snapshot| snapshot.sequence),
1075 Some(1)
1076 );
1077 }
1078
1079 #[test]
1080 fn counts_external_processes_toward_history_limits() {
1081 let mut history = HistoryRecorder::default();
1082 let external_processes = (1..=128)
1083 .map(|pid| ExternalProcess {
1084 pid,
1085 start_time_ms: Some(u64::from(pid) * 1_000),
1086 })
1087 .collect::<Vec<_>>();
1088 let snapshot = SnapshotEvent {
1089 version: PROTOCOL_VERSION,
1090 event_type: "snapshot",
1091 sequence: 0,
1092 sampled_at_unix_ms: 0,
1093 collection_duration_micros: 1,
1094 scanned_process_count: 0,
1095 retained_process_count: 0,
1096 inaccessible_process_count: 0,
1097 request_id: None,
1098 external_processes,
1099 processes: Vec::new(),
1100 };
1101 let snapshot_bytes = snapshot.estimated_history_bytes();
1102 let snapshot_entries = snapshot.retained_entry_count();
1103
1104 for sequence in 0..3 {
1105 history.record_with_limits(
1106 &SnapshotEvent {
1107 sequence,
1108 sampled_at_unix_ms: sequence * 1_000,
1109 ..snapshot.clone()
1110 },
1111 3,
1112 snapshot_entries * 2,
1113 snapshot_bytes * 2,
1114 );
1115 }
1116
1117 assert_eq!(history.retained_entry_count, snapshot_entries * 2);
1118 assert!(history.retained_bytes <= snapshot_bytes * 2);
1119 assert_eq!(history.snapshots.len(), 2);
1120 assert_eq!(
1121 history.snapshots.front().map(|snapshot| snapshot.sequence),
1122 Some(1)
1123 );
1124 }
1125
1126 #[test]
1127 fn truncates_process_strings_at_utf8_boundaries() {
1128 let value = "é".repeat(MAX_PROCESS_NAME_BYTES);
1129 let truncated = truncate_utf8(value, MAX_PROCESS_NAME_BYTES - 1);
1130
1131 assert!(truncated.len() < MAX_PROCESS_NAME_BYTES);
1132 assert!(truncated.is_char_boundary(truncated.len()));
1133 }
1134
1135 #[test]
1136 fn refreshes_commands_without_enumerating_linux_tasks() {
1137 let refresh_kind = process_refresh_kind();
1138
1139 assert_eq!(refresh_kind.cmd(), UpdateKind::Always);
1140 assert!(!refresh_kind.tasks());
1141 assert!(refresh_kind.cpu());
1142 assert!(refresh_kind.memory());
1143 assert!(refresh_kind.disk_usage());
1144 }
1145
1146 #[test]
1147 fn sample_now_does_not_postpone_an_existing_periodic_deadline() {
1148 let now = Instant::now();
1149 let deadline = now + Duration::from_secs(1);
1150
1151 assert_eq!(
1152 sample_now_deadline(
1153 Some(deadline),
1154 Some(Duration::from_secs(5)),
1155 now + Duration::from_millis(100)
1156 ),
1157 Some(deadline)
1158 );
1159 }
1160}
package.json+2
......@@ -20,11 +20,13 @@
2020 "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build",
2121 "build:marketing": "vp run --filter @t3tools/marketing build",
2222 "build:desktop": "vp run --filter @t3tools/desktop --filter t3 build",
23 "build:resource-monitor": "cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml",
2324 "typecheck": "vp run -r --concurrency-limit 2 typecheck",
2425 "tc": "vp run -r --concurrency-limit 2 typecheck",
2526 "lint": "vp lint --report-unused-disable-directives",
2627 "lint:mobile": "node scripts/mobile-native-static-check.ts",
2728 "test": "vp run -r test",
29 "test:resource-monitor": "cargo test --locked --manifest-path native/resource-monitor/Cargo.toml",
2830 "test:desktop-smoke": "vp run --filter @t3tools/desktop smoke-test",
2931 "fmt": "vp fmt",
3032 "fmt:check": "vp fmt --check",
packages/client-runtime/src/rpc/client.ts+106-87
......@@ -49,6 +49,7 @@ export type EnvironmentSubscriptionRpcTag =
4949 | typeof WS_METHODS.subscribeTerminalMetadata
5050 | typeof WS_METHODS.subscribePreviewEvents
5151 | typeof WS_METHODS.subscribeDiscoveredLocalServers
52 | typeof WS_METHODS.subscribeResourceTelemetry
5253 | typeof WS_METHODS.previewAutomationConnect
5354 | typeof WS_METHODS.subscribeVcsStatus
5455 | typeof WS_METHODS.terminalAttach;
......@@ -62,6 +63,23 @@ export type EnvironmentStreamRpcTag =
6263 | EnvironmentStreamCommandRpcTag;
6364
6465export type EnvironmentUnaryRpcTag = Exclude<EnvironmentRpcTag, EnvironmentStreamRpcTag>;
66
67export interface EnvironmentRpcSubscriptionObservation {
68 readonly environmentId: string;
69 readonly method: EnvironmentSubscriptionRpcTag;
70 readonly input: unknown;
71}
72
73export class EnvironmentRpcSubscriptionObserver extends Context.Reference<{
74 readonly observe: (
75 subscription: EnvironmentRpcSubscriptionObservation,
76 ) => Effect.Effect<Effect.Effect<void>>;
77}>("@t3tools/client-runtime/rpc/EnvironmentRpcSubscriptionObserver", {
78 defaultValue: () => ({
79 observe: () => Effect.succeed(Effect.void),
80 }),
81}) {}
82
6583const isRpcClientError = Schema.is(RpcClientError.RpcClientError);
6684
6785export type EnvironmentRpcInput<TTag extends EnvironmentRpcTag> = Parameters<RpcMethod<TTag>>[0];
......@@ -166,93 +184,94 @@ export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>(
166184 EnvironmentSupervisor
167185> {
168186 return Stream.unwrap(
169 EnvironmentSupervisor.pipe(
170 Effect.map((supervisor) => {
171 const sessionChanges = SubscriptionRef.changes(supervisor.session);
172 const sessions =
173 options?.resubscribe === undefined
174 ? sessionChanges
175 : Stream.merge(
176 sessionChanges,
177 options.resubscribe.pipe(
178 Stream.mapEffect(() => SubscriptionRef.get(supervisor.session)),
179 ),
180 );
181 return sessions.pipe(
182 Stream.switchMap(
183 Option.match({
184 onNone: () => Stream.empty,
185 onSome: (session) => {
186 const method = session.client[tag] as (
187 input: EnvironmentRpcInput<TTag>,
188 ) => Stream.Stream<
189 EnvironmentRpcStreamValue<TTag>,
190 EnvironmentRpcStreamFailure<TTag>
191 >;
192 const subscribeToSession = (): Stream.Stream<
193 EnvironmentRpcStreamValue<TTag>,
194 EnvironmentRpcStreamFailure<TTag>
195 > =>
196 Stream.suspend(() =>
197 Stream.unwrap(
198 makeInput(session).pipe(
199 Effect.map((input) =>
200 method(input).pipe(
201 Stream.catchCause((cause) => {
202 const hasOnlyExpectedFailures =
203 cause.reasons.length > 0 &&
204 cause.reasons.every((reason) => reason._tag === "Fail");
205 const isTransportFailure =
206 hasOnlyExpectedFailures &&
207 cause.reasons.every(
208 (reason) =>
209 reason._tag === "Fail" && isRpcClientError(reason.error),
210 );
211 if (isTransportFailure) {
212 return Stream.fromEffect(
213 Effect.logWarning(
214 "Durable RPC subscription lost its transport; waiting for the next session.",
215 {
216 cause: Cause.pretty(cause),
217 method: tag,
218 environmentId: supervisor.target.environmentId,
219 },
220 ),
221 ).pipe(Stream.drain);
222 }
223 if (
224 hasOnlyExpectedFailures &&
225 options?.onExpectedFailure !== undefined
226 ) {
227 const handled = Stream.fromEffect(
228 options.onExpectedFailure(cause),
229 ).pipe(Stream.drain);
230 if (options.retryExpectedFailureAfter === undefined) {
231 return handled;
232 }
233 return handled.pipe(
234 Stream.concat(
235 Stream.fromEffect(
236 Effect.sleep(options.retryExpectedFailureAfter),
237 ).pipe(Stream.drain),
238 ),
239 Stream.concat(subscribeToSession()),
240 );
241 }
242 return Stream.failCause(cause);
243 }),
244 ),
245 ),
246 ),
247 ),
248 );
249 return subscribeToSession();
250 },
251 }),
252 ),
253 );
254 }),
255 ),
187 Effect.gen(function* () {
188 const supervisor = yield* EnvironmentSupervisor;
189 const observer = yield* EnvironmentRpcSubscriptionObserver;
190 const sessionChanges = SubscriptionRef.changes(supervisor.session);
191 const sessions =
192 options?.resubscribe === undefined
193 ? sessionChanges
194 : Stream.merge(
195 sessionChanges,
196 options.resubscribe.pipe(
197 Stream.mapEffect(() => SubscriptionRef.get(supervisor.session)),
198 ),
199 );
200 return sessions.pipe(
201 Stream.switchMap(
202 Option.match({
203 onNone: () => Stream.empty,
204 onSome: (session) => {
205 const method = session.client[tag] as (
206 input: EnvironmentRpcInput<TTag>,
207 ) => Stream.Stream<
208 EnvironmentRpcStreamValue<TTag>,
209 EnvironmentRpcStreamFailure<TTag>
210 >;
211 const subscribeToSession = (): Stream.Stream<
212 EnvironmentRpcStreamValue<TTag>,
213 EnvironmentRpcStreamFailure<TTag>
214 > =>
215 Stream.suspend(() =>
216 Stream.unwrap(
217 Effect.gen(function* () {
218 const input = yield* makeInput(session);
219 const completeObservation = yield* observer.observe({
220 environmentId: supervisor.target.environmentId,
221 method: tag,
222 input,
223 });
224 return method(input).pipe(
225 Stream.ensuring(completeObservation),
226 Stream.catchCause((cause) => {
227 const hasOnlyExpectedFailures =
228 cause.reasons.length > 0 &&
229 cause.reasons.every((reason) => reason._tag === "Fail");
230 const isTransportFailure =
231 hasOnlyExpectedFailures &&
232 cause.reasons.every(
233 (reason) => reason._tag === "Fail" && isRpcClientError(reason.error),
234 );
235 if (isTransportFailure) {
236 return Stream.fromEffect(
237 Effect.logWarning(
238 "Durable RPC subscription lost its transport; waiting for the next session.",
239 {
240 cause: Cause.pretty(cause),
241 method: tag,
242 environmentId: supervisor.target.environmentId,
243 },
244 ),
245 ).pipe(Stream.drain);
246 }
247 if (hasOnlyExpectedFailures && options?.onExpectedFailure !== undefined) {
248 const handled = Stream.fromEffect(
249 options.onExpectedFailure(cause),
250 ).pipe(Stream.drain);
251 if (options.retryExpectedFailureAfter === undefined) {
252 return handled;
253 }
254 return handled.pipe(
255 Stream.concat(
256 Stream.fromEffect(
257 Effect.sleep(options.retryExpectedFailureAfter),
258 ).pipe(Stream.drain),
259 ),
260 Stream.concat(subscribeToSession()),
261 );
262 }
263 return Stream.failCause(cause);
264 }),
265 );
266 }),
267 ),
268 );
269 return subscribeToSession();
270 },
271 }),
272 ),
273 );
274 }),
256275 ).pipe(
257276 Stream.withSpan("EnvironmentRpc.subscribe", {
258277 attributes: { "rpc.method": tag },
packages/client-runtime/src/state/server.ts+18
......@@ -298,6 +298,16 @@ export function createServerEnvironmentAtoms<R, E>(
298298 label: "environment-data:server:process-resource-history",
299299 tag: WS_METHODS.serverGetProcessResourceHistory,
300300 }),
301 resourceTelemetry: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
302 label: "environment-data:server:resource-telemetry",
303 tag: WS_METHODS.subscribeResourceTelemetry,
304 idleTtlMs: 0,
305 }),
306 resourceTelemetryHistory: createEnvironmentRpcQueryAtomFamily(runtime, {
307 label: "environment-data:server:resource-telemetry-history",
308 tag: WS_METHODS.serverGetResourceTelemetryHistory,
309 staleTimeMs: 5_000,
310 }),
301311 configProjection,
302312 welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
303313 label: "environment-data:server:welcome",
......@@ -349,5 +359,13 @@ export function createServerEnvironmentAtoms<R, E>(
349359 label: "environment-data:server:signal-process",
350360 tag: WS_METHODS.serverSignalProcess,
351361 }),
362 retryResourceTelemetry: createEnvironmentRpcCommand(runtime, {
363 label: "environment-data:server:retry-resource-telemetry",
364 tag: WS_METHODS.serverRetryResourceTelemetry,
365 concurrency: {
366 mode: "singleFlight",
367 key: ({ environmentId }) => environmentId,
368 },
369 }),
352370 };
353371}
packages/contracts/src/background.test.ts created+18
......@@ -0,0 +1,18 @@
1import * as Schema from "effect/Schema";
2import { describe, expect, it } from "vite-plus/test";
3
4import { ClientActivityClientId } from "./background.ts";
5
6const decodeClientActivityClientId = Schema.decodeUnknownSync(ClientActivityClientId);
7
8describe("ClientActivityClientId", () => {
9 it("trims and accepts bounded client identifiers", () => {
10 expect(decodeClientActivityClientId(" client-1 ")).toBe("client-1");
11 expect(decodeClientActivityClientId("x".repeat(128))).toBe("x".repeat(128));
12 });
13
14 it("rejects empty and oversized client identifiers", () => {
15 expect(() => decodeClientActivityClientId(" ")).toThrow();
16 expect(() => decodeClientActivityClientId("x".repeat(129))).toThrow();
17 });
18});
packages/contracts/src/background.ts created+110
......@@ -0,0 +1,110 @@
1import * as Schema from "effect/Schema";
2
3import {
4 AuthSessionId,
5 EnvironmentId,
6 RpcClientId,
7 ThreadId,
8 TrimmedNonEmptyString,
9} from "./baseSchemas.ts";
10import { ProviderInstanceId } from "./providerInstance.ts";
11
12export const BackgroundBooleanState = Schema.Literals(["true", "false", "unknown"]);
13export type BackgroundBooleanState = typeof BackgroundBooleanState.Type;
14
15export const HostPowerThermalState = Schema.Literals([
16 "unknown",
17 "nominal",
18 "fair",
19 "serious",
20 "critical",
21]);
22export type HostPowerThermalState = typeof HostPowerThermalState.Type;
23
24export const HostPowerSource = Schema.Literals([
25 "unknown",
26 "node-macos-shell",
27 "node-macos-native",
28 "node-linux",
29 "node-windows",
30 "electron-main",
31]);
32export type HostPowerSource = typeof HostPowerSource.Type;
33
34export const HostPowerSnapshot = Schema.Struct({
35 source: HostPowerSource,
36 idle: BackgroundBooleanState,
37 idleSeconds: Schema.NullOr(Schema.Number),
38 locked: BackgroundBooleanState,
39 suspended: Schema.Boolean,
40 onBattery: BackgroundBooleanState,
41 lowPowerMode: BackgroundBooleanState,
42 thermalState: HostPowerThermalState,
43 stale: Schema.Boolean,
44 updatedAt: Schema.DateTimeUtc,
45});
46export type HostPowerSnapshot = typeof HostPowerSnapshot.Type;
47
48export const BackgroundScope = Schema.Union([
49 Schema.Struct({ type: Schema.Literal("server-config") }),
50 Schema.Struct({
51 type: Schema.Literal("provider-status"),
52 instanceId: Schema.optionalKey(ProviderInstanceId),
53 }),
54 Schema.Struct({ type: Schema.Literal("vcs-status"), cwd: Schema.String }),
55 Schema.Struct({ type: Schema.Literal("git-refs"), cwd: Schema.String }),
56 Schema.Struct({ type: Schema.Literal("diagnostics") }),
57 Schema.Struct({ type: Schema.Literal("thread"), threadId: ThreadId }),
58]);
59export type BackgroundScope = typeof BackgroundScope.Type;
60
61export const ClientKind = Schema.Literals(["web", "desktop-renderer", "mobile", "unknown"]);
62export type ClientKind = typeof ClientKind.Type;
63
64export const ClientActivityClientId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));
65export type ClientActivityClientId = typeof ClientActivityClientId.Type;
66
67export const ClientActivityReportInput = Schema.Struct({
68 environmentId: Schema.optionalKey(EnvironmentId),
69 clientId: ClientActivityClientId,
70 clientKind: ClientKind,
71 visible: Schema.Boolean,
72 focused: Schema.Boolean,
73 recentlyInteracted: Schema.Boolean,
74 appState: Schema.optionalKey(Schema.Literals(["active", "inactive", "background", "unknown"])),
75 lowPowerMode: Schema.optionalKey(BackgroundBooleanState),
76 batteryState: Schema.optionalKey(Schema.Literals(["unknown", "unplugged", "charging", "full"])),
77 networkType: Schema.optionalKey(Schema.String),
78 scopes: Schema.Array(BackgroundScope),
79 ttlMs: Schema.optionalKey(Schema.Number),
80 observedAt: Schema.DateTimeUtc,
81});
82export type ClientActivityReportInput = typeof ClientActivityReportInput.Type;
83
84export const ClientActivityLease = Schema.Struct({
85 sessionId: AuthSessionId,
86 rpcClientId: RpcClientId,
87 clientId: ClientActivityClientId,
88 clientKind: ClientKind,
89 visible: Schema.Boolean,
90 focused: Schema.Boolean,
91 recentlyInteracted: Schema.Boolean,
92 appState: Schema.optionalKey(Schema.Literals(["active", "inactive", "background", "unknown"])),
93 lowPowerMode: Schema.optionalKey(BackgroundBooleanState),
94 batteryState: Schema.optionalKey(Schema.Literals(["unknown", "unplugged", "charging", "full"])),
95 networkType: Schema.optionalKey(Schema.String),
96 scopes: Schema.Array(BackgroundScope),
97 updatedAt: Schema.DateTimeUtc,
98 expiresAt: Schema.DateTimeUtc,
99});
100export type ClientActivityLease = typeof ClientActivityLease.Type;
101
102export const BackgroundPolicySnapshot = Schema.Struct({
103 hostPower: HostPowerSnapshot,
104 leases: Schema.Array(ClientActivityLease),
105 activeForegroundLeaseCount: Schema.Number,
106 activeScopeKeys: Schema.Array(Schema.String),
107 shouldRunOpportunisticWork: Schema.Boolean,
108 updatedAt: Schema.DateTimeUtc,
109});
110export type BackgroundPolicySnapshot = typeof BackgroundPolicySnapshot.Type;
packages/contracts/src/baseSchemas.ts+2
......@@ -43,6 +43,8 @@ export const TurnId = makeEntityId("TurnId");
4343export type TurnId = typeof TurnId.Type;
4444export const AuthSessionId = makeEntityId("AuthSessionId");
4545export type AuthSessionId = typeof AuthSessionId.Type;
46export const RpcClientId = NonNegativeInt.pipe(Schema.brand("RpcClientId"));
47export type RpcClientId = typeof RpcClientId.Type;
4648
4749export const ProviderItemId = makeEntityId("ProviderItemId");
4850export type ProviderItemId = typeof ProviderItemId.Type;
packages/contracts/src/desktopBootstrap.ts+4-1
......@@ -1,6 +1,6 @@
11import * as Schema from "effect/Schema";
22
3import { PortSchema } from "./baseSchemas.ts";
3import { PortSchema, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
44
55export const DesktopBackendBootstrap = Schema.Struct({
66 mode: Schema.Literal("desktop"),
......@@ -16,6 +16,9 @@ export const DesktopBackendBootstrap = Schema.Struct({
1616 tailscaleServePort: PortSchema,
1717 otlpTracesUrl: Schema.optional(Schema.String),
1818 otlpMetricsUrl: Schema.optional(Schema.String),
19 desktopTelemetryFd: Schema.optionalKey(PositiveInt),
20 desktopTelemetryControlFd: Schema.optionalKey(PositiveInt),
21 resourceMonitorPath: Schema.optionalKey(TrimmedNonEmptyString),
1922});
2023
2124export type DesktopBackendBootstrap = typeof DesktopBackendBootstrap.Type;
packages/contracts/src/index.ts+2
......@@ -1,4 +1,5 @@
11export * from "./baseSchemas.ts";
2export * from "./background.ts";
23export * from "./auth.ts";
34export * from "./environment.ts";
45export * from "./environmentHttp.ts";
......@@ -26,4 +27,5 @@ export * from "./assets.ts";
2627export * from "./review.ts";
2728export * from "./preview.ts";
2829export * from "./previewAutomation.ts";
30export * from "./resourceTelemetry.ts";
2931export * from "./rpc.ts";
packages/contracts/src/ipc.ts+2-43
......@@ -31,20 +31,6 @@ import type {
3131 ProjectWriteFileInput,
3232 ProjectWriteFileResult,
3333} from "./project.ts";
34import type { ProviderInstanceId } from "./providerInstance.ts";
35import type {
36 ServerConfig,
37 ServerProcessDiagnosticsResult,
38 ServerProcessResourceHistoryInput,
39 ServerProcessResourceHistoryResult,
40 ServerProviderUpdateInput,
41 ServerProviderUpdatedPayload,
42 ServerRemoveKeybindingResult,
43 ServerSignalProcessInput,
44 ServerSignalProcessResult,
45 ServerTraceDiagnosticsResult,
46 ServerUpsertKeybindingResult,
47} from "./server.ts";
4834import type {
4935 TerminalAttachInput,
5036 TerminalAttachStreamEvent,
......@@ -57,7 +43,6 @@ import type {
5743 TerminalSessionSnapshot,
5844 TerminalWriteInput,
5945} from "./terminal.ts";
60import type { ServerRemoveKeybindingInput, ServerUpsertKeybindingInput } from "./server.ts";
6146import * as Schema from "effect/Schema";
6247import type {
6348 DiscoveredLocalServerList,
......@@ -100,13 +85,11 @@ import type {
10085import { EnvironmentId } from "./baseSchemas.ts";
10186import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts";
10287import { AdvertisedEndpoint } from "./remoteAccess.ts";
103import { EditorId } from "./editor.ts";
10488import { ExecutionEnvironmentDescriptor } from "./environment.ts";
105import type { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings.ts";
89import type { ClientSettings } from "./settings.ts";
10690import type {
10791 SourceControlCloneRepositoryInput,
10892 SourceControlCloneRepositoryResult,
109 SourceControlDiscoveryResult,
11093 SourceControlPublishRepositoryInput,
11194 SourceControlPublishRepositoryResult,
11295 SourceControlRepositoryInfo,
......@@ -1115,7 +1098,7 @@ export interface DesktopPreviewBridge {
11151098 * APIs bound to the local app shell, not to any particular backend environment.
11161099 *
11171100 * These capabilities describe the desktop/browser host that the user is
1118 * currently running: dialogs, editor/external-link opening, context menus, and
1101 * currently running: dialogs, external-link opening, context menus, and
11191102 * app-level settings/config access. They must not be used as a proxy for
11201103 * "whatever environment the user is targeting", because in a multi-environment
11211104 * world the local shell and a selected backend environment are distinct
......@@ -1127,7 +1110,6 @@ export interface LocalApi {
11271110 confirm: (message: string) => Promise<boolean>;
11281111 };
11291112 shell: {
1130 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;
11311113 openExternal: (url: string) => Promise<void>;
11321114 };
11331115 contextMenu: {
......@@ -1140,29 +1122,6 @@ export interface LocalApi {
11401122 getClientSettings: () => Promise<ClientSettings | null>;
11411123 setClientSettings: (settings: ClientSettings) => Promise<void>;
11421124 };
1143 server: {
1144 getConfig: () => Promise<ServerConfig>;
1145 /**
1146 * Refresh provider snapshots. When `input.instanceId` is supplied only that
1147 * configured instance is probed; otherwise every configured instance is
1148 * refreshed (legacy untargeted refresh).
1149 */
1150 refreshProviders: (input?: {
1151 readonly instanceId?: ProviderInstanceId;
1152 }) => Promise<ServerProviderUpdatedPayload>;
1153 updateProvider: (input: ServerProviderUpdateInput) => Promise<ServerProviderUpdatedPayload>;
1154 upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>;
1155 removeKeybinding: (input: ServerRemoveKeybindingInput) => Promise<ServerRemoveKeybindingResult>;
1156 getSettings: () => Promise<ServerSettings>;
1157 updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>;
1158 discoverSourceControl: () => Promise<SourceControlDiscoveryResult>;
1159 getTraceDiagnostics: () => Promise<ServerTraceDiagnosticsResult>;
1160 getProcessDiagnostics: () => Promise<ServerProcessDiagnosticsResult>;
1161 getProcessResourceHistory: (
1162 input: ServerProcessResourceHistoryInput,
1163 ) => Promise<ServerProcessResourceHistoryResult>;
1164 signalProcess: (input: ServerSignalProcessInput) => Promise<ServerSignalProcessResult>;
1165 };
11661125}
11671126
11681127/**
packages/contracts/src/resourceTelemetry.ts created+430
......@@ -0,0 +1,430 @@
1import * as Schema from "effect/Schema";
2
3import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
4import { HostPowerSnapshot } from "./background.ts";
5
6export const RESOURCE_MONITOR_PROTOCOL_VERSION = 2 as const;
7
8export const ResourceTelemetryIoSemantics = Schema.Literals([
9 "storage",
10 "logical",
11 "all-io",
12 "unavailable",
13]);
14export type ResourceTelemetryIoSemantics = typeof ResourceTelemetryIoSemantics.Type;
15
16export const ResourceTelemetryProcessCategory = Schema.Literals([
17 "server",
18 "server-child",
19 "provider-root",
20 "terminal-root",
21 "electron-main",
22 "electron-renderer",
23 "electron-gpu",
24 "electron-utility",
25 "resource-monitor",
26 "unknown-t3",
27]);
28export type ResourceTelemetryProcessCategory = typeof ResourceTelemetryProcessCategory.Type;
29
30export const ResourceTelemetrySourceStatus = Schema.Literals([
31 "starting",
32 "healthy",
33 "degraded",
34 "unavailable",
35 "stopped",
36]);
37export type ResourceTelemetrySourceStatus = typeof ResourceTelemetrySourceStatus.Type;
38
39export const ResourceTelemetryProcessIdentity = Schema.Struct({
40 pid: PositiveInt,
41 startTimeMs: NonNegativeInt,
42});
43export type ResourceTelemetryProcessIdentity = typeof ResourceTelemetryProcessIdentity.Type;
44
45export const ResourceMonitorExternalProcess = Schema.Struct({
46 pid: PositiveInt,
47 startTimeMs: Schema.optionalKey(NonNegativeInt),
48});
49export type ResourceMonitorExternalProcess = typeof ResourceMonitorExternalProcess.Type;
50
51export const ResourceMonitorCapabilities = Schema.Struct({
52 cumulativeCpuTime: Schema.Boolean,
53 currentCpuPercent: Schema.Boolean,
54 residentMemory: Schema.Boolean,
55 virtualMemory: Schema.Boolean,
56 ioBytes: Schema.Boolean,
57 processStartTime: Schema.Boolean,
58 processTree: Schema.Boolean,
59});
60export type ResourceMonitorCapabilities = typeof ResourceMonitorCapabilities.Type;
61
62export const ResourceMonitorProcessSample = Schema.Struct({
63 pid: PositiveInt,
64 ppid: NonNegativeInt,
65 startTimeMs: NonNegativeInt,
66 runTimeMs: NonNegativeInt,
67 name: Schema.String,
68 command: Schema.String,
69 status: Schema.String,
70 cpuPercent: Schema.Number,
71 cpuTimeMs: NonNegativeInt,
72 residentBytes: NonNegativeInt,
73 virtualBytes: NonNegativeInt,
74 ioReadBytes: NonNegativeInt,
75 ioWriteBytes: NonNegativeInt,
76 ioSemantics: Schema.Literals(["storage", "all-io"]),
77});
78export type ResourceMonitorProcessSample = typeof ResourceMonitorProcessSample.Type;
79
80export const ResourceMonitorConfigureCommand = Schema.Struct({
81 version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
82 type: Schema.Literal("configure"),
83 rootPid: PositiveInt,
84 sampleIntervalMs: NonNegativeInt,
85 externalProcesses: Schema.Array(ResourceMonitorExternalProcess),
86});
87export type ResourceMonitorConfigureCommand = typeof ResourceMonitorConfigureCommand.Type;
88
89export const ResourceMonitorSetExternalProcessesCommand = Schema.Struct({
90 version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
91 type: Schema.Literal("setExternalProcesses"),
92 processes: Schema.Array(ResourceMonitorExternalProcess),
93});
94export type ResourceMonitorSetExternalProcessesCommand =
95 typeof ResourceMonitorSetExternalProcessesCommand.Type;
96
97export const ResourceMonitorSampleNowCommand = Schema.Struct({
98 version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
99 type: Schema.Literal("sampleNow"),
100 requestId: TrimmedNonEmptyString,
101});
102export type ResourceMonitorSampleNowCommand = typeof ResourceMonitorSampleNowCommand.Type;
103
104export const ResourceMonitorSetSampleIntervalCommand = Schema.Struct({
105 version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
106 type: Schema.Literal("setSampleInterval"),
107 sampleIntervalMs: NonNegativeInt,
108});
109export type ResourceMonitorSetSampleIntervalCommand =
110 typeof ResourceMonitorSetSampleIntervalCommand.Type;
111
112export const ResourceMonitorSetStreamingCommand = Schema.Struct({
113 version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
114 type: Schema.Literal("setStreaming"),
115 enabled: Schema.Boolean,
116});
117export type ResourceMonitorSetStreamingCommand = typeof ResourceMonitorSetStreamingCommand.Type;
118
119export const ResourceMonitorReadHistoryCommand = Schema.Struct({
120 version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
121 type: Schema.Literal("readHistory"),
122 requestId: TrimmedNonEmptyString,
123 windowMs: NonNegativeInt,
124});
125export type ResourceMonitorReadHistoryCommand = typeof ResourceMonitorReadHistoryCommand.Type;
126
127export const ResourceMonitorShutdownCommand = Schema.Struct({
128 version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
129 type: Schema.Literal("shutdown"),
130});
131export type ResourceMonitorShutdownCommand = typeof ResourceMonitorShutdownCommand.Type;
132
133export const ResourceMonitorCommand = Schema.Union([
134 ResourceMonitorConfigureCommand,
135 ResourceMonitorSetExternalProcessesCommand,
136 ResourceMonitorSetSampleIntervalCommand,
137 ResourceMonitorSetStreamingCommand,
138 ResourceMonitorSampleNowCommand,
139 ResourceMonitorReadHistoryCommand,
140 ResourceMonitorShutdownCommand,
141]);
142export type ResourceMonitorCommand = typeof ResourceMonitorCommand.Type;
143
144export const ResourceMonitorHelloEvent = Schema.Struct({
145 version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
146 type: Schema.Literal("hello"),
147 sidecarVersion: TrimmedNonEmptyString,
148 sidecarPid: PositiveInt,
149 platform: TrimmedNonEmptyString,
150 arch: TrimmedNonEmptyString,
151 capabilities: ResourceMonitorCapabilities,
152});
153export type ResourceMonitorHelloEvent = typeof ResourceMonitorHelloEvent.Type;
154
155export const ResourceMonitorSnapshotEvent = Schema.Struct({
156 version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
157 type: Schema.Literal("snapshot"),
158 sequence: NonNegativeInt,
159 sampledAtUnixMs: NonNegativeInt,
160 collectionDurationMicros: NonNegativeInt,
161 scannedProcessCount: NonNegativeInt,
162 retainedProcessCount: NonNegativeInt,
163 inaccessibleProcessCount: NonNegativeInt,
164 requestId: Schema.optionalKey(TrimmedNonEmptyString),
165 externalProcesses: Schema.optionalKey(Schema.Array(ResourceMonitorExternalProcess)),
166 processes: Schema.Array(ResourceMonitorProcessSample),
167});
168export type ResourceMonitorSnapshotEvent = typeof ResourceMonitorSnapshotEvent.Type;
169
170export const ResourceMonitorHistoryChunkEvent = Schema.Struct({
171 version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
172 type: Schema.Literal("historyChunk"),
173 requestId: TrimmedNonEmptyString,
174 done: Schema.Boolean,
175 snapshots: Schema.Array(ResourceMonitorSnapshotEvent),
176});
177export type ResourceMonitorHistoryChunkEvent = typeof ResourceMonitorHistoryChunkEvent.Type;
178
179export const ResourceMonitorErrorEvent = Schema.Struct({
180 version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION),
181 type: Schema.Literal("error"),
182 code: TrimmedNonEmptyString,
183 message: TrimmedNonEmptyString,
184 recoverable: Schema.Boolean,
185});
186export type ResourceMonitorErrorEvent = typeof ResourceMonitorErrorEvent.Type;
187
188export const ResourceMonitorEvent = Schema.Union([
189 ResourceMonitorHelloEvent,
190 ResourceMonitorSnapshotEvent,
191 ResourceMonitorHistoryChunkEvent,
192 ResourceMonitorErrorEvent,
193]);
194export type ResourceMonitorEvent = typeof ResourceMonitorEvent.Type;
195
196export const DesktopElectronProcessType = Schema.Literals([
197 "Browser",
198 "Tab",
199 "Utility",
200 "Zygote",
201 "Sandbox helper",
202 "GPU",
203 "Pepper Plugin",
204 "Pepper Plugin Broker",
205 "Unknown",
206]);
207export type DesktopElectronProcessType = typeof DesktopElectronProcessType.Type;
208
209export const DesktopElectronProcessMetric = Schema.Struct({
210 pid: PositiveInt,
211 creationTimeMs: NonNegativeInt,
212 type: DesktopElectronProcessType,
213 name: Schema.optionalKey(Schema.String),
214 serviceName: Schema.optionalKey(Schema.String),
215 cpuPercent: Schema.Number,
216 cumulativeCpuSeconds: Schema.optionalKey(Schema.Number),
217 idleWakeupsPerSecond: Schema.Number,
218 workingSetBytes: NonNegativeInt,
219 peakWorkingSetBytes: NonNegativeInt,
220});
221export type DesktopElectronProcessMetric = typeof DesktopElectronProcessMetric.Type;
222
223const DesktopHostPowerSnapshot = Schema.Struct({
224 ...HostPowerSnapshot.fields,
225 updatedAt: Schema.DateTimeUtcFromString,
226});
227
228export const DesktopHostTelemetrySnapshot = Schema.Struct({
229 version: Schema.Literal(1),
230 type: Schema.Literal("desktopTelemetry"),
231 sequence: NonNegativeInt,
232 sampledAtUnixMs: NonNegativeInt,
233 electronPid: PositiveInt,
234 power: DesktopHostPowerSnapshot,
235 speedLimitPercent: Schema.OptionFromNullOr(Schema.Number),
236 electronProcesses: Schema.Array(DesktopElectronProcessMetric),
237});
238export type DesktopHostTelemetrySnapshot = typeof DesktopHostTelemetrySnapshot.Type;
239
240export const DesktopHostTelemetryHello = Schema.Struct({
241 version: Schema.Literal(1),
242 type: Schema.Literal("desktopTelemetryHello"),
243 electronPid: PositiveInt,
244});
245export type DesktopHostTelemetryHello = typeof DesktopHostTelemetryHello.Type;
246
247export const DesktopHostTelemetryMessage = Schema.Union([
248 DesktopHostTelemetryHello,
249 DesktopHostTelemetrySnapshot,
250]);
251export type DesktopHostTelemetryMessage = typeof DesktopHostTelemetryMessage.Type;
252
253export const DesktopTelemetrySetDiagnosticsDemand = Schema.Struct({
254 version: Schema.Literal(1),
255 type: Schema.Literal("setDiagnosticsDemand"),
256 enabled: Schema.Boolean,
257});
258export type DesktopTelemetrySetDiagnosticsDemand = typeof DesktopTelemetrySetDiagnosticsDemand.Type;
259
260export const DesktopTelemetrySetHostPowerIntervals = Schema.Struct({
261 version: Schema.Literal(1),
262 type: Schema.Literal("setHostPowerIntervals"),
263 activeIntervalMs: PositiveInt,
264 idleIntervalMs: PositiveInt,
265});
266export type DesktopTelemetrySetHostPowerIntervals =
267 typeof DesktopTelemetrySetHostPowerIntervals.Type;
268
269export const DesktopTelemetryControlMessage = Schema.Union([
270 DesktopTelemetrySetDiagnosticsDemand,
271 DesktopTelemetrySetHostPowerIntervals,
272]);
273export type DesktopTelemetryControlMessage = typeof DesktopTelemetryControlMessage.Type;
274
275export const ResourceTelemetryProcess = Schema.Struct({
276 identity: ResourceTelemetryProcessIdentity,
277 ppid: NonNegativeInt,
278 childPids: Schema.Array(PositiveInt),
279 depth: NonNegativeInt,
280 name: Schema.String,
281 command: Schema.String,
282 status: Schema.String,
283 category: ResourceTelemetryProcessCategory,
284 electronType: Schema.optionalKey(DesktopElectronProcessType),
285 electronServiceName: Schema.optionalKey(Schema.String),
286 cpuPercent: Schema.Number,
287 cpuTimeMs: NonNegativeInt,
288 residentBytes: NonNegativeInt,
289 peakResidentBytes: NonNegativeInt,
290 virtualBytes: NonNegativeInt,
291 ioReadBytes: NonNegativeInt,
292 ioWriteBytes: NonNegativeInt,
293 ioReadBytesPerSecond: Schema.Number,
294 ioWriteBytesPerSecond: Schema.Number,
295 ioSemantics: ResourceTelemetryIoSemantics,
296 idleWakeupsPerSecond: Schema.optionalKey(Schema.Number),
297 runTimeMs: NonNegativeInt,
298 firstSeenAt: Schema.DateTimeUtc,
299 lastSeenAt: Schema.DateTimeUtc,
300});
301export type ResourceTelemetryProcess = typeof ResourceTelemetryProcess.Type;
302
303export const ResourceTelemetryAggregate = Schema.Struct({
304 processCount: NonNegativeInt,
305 currentCpuPercent: Schema.Number,
306 cpuTimeMs: NonNegativeInt,
307 currentRssBytes: NonNegativeInt,
308 peakRssBytes: NonNegativeInt,
309 ioReadBytes: NonNegativeInt,
310 ioWriteBytes: NonNegativeInt,
311 ioReadBytesPerSecond: Schema.Number,
312 ioWriteBytesPerSecond: Schema.Number,
313 processStarts: NonNegativeInt,
314 processExits: NonNegativeInt,
315});
316export type ResourceTelemetryAggregate = typeof ResourceTelemetryAggregate.Type;
317
318export const ResourceTelemetryGroups = Schema.Struct({
319 backend: ResourceTelemetryAggregate,
320 electron: ResourceTelemetryAggregate,
321 monitor: ResourceTelemetryAggregate,
322 allT3: ResourceTelemetryAggregate,
323});
324export type ResourceTelemetryGroups = typeof ResourceTelemetryGroups.Type;
325
326export const ResourceTelemetrySourceHealth = Schema.Struct({
327 status: ResourceTelemetrySourceStatus,
328 lastSampleAt: Schema.Option(Schema.DateTimeUtc),
329 lastError: Schema.Option(TrimmedNonEmptyString),
330});
331export type ResourceTelemetrySourceHealth = typeof ResourceTelemetrySourceHealth.Type;
332
333export const ResourceTelemetryHealth = Schema.Struct({
334 native: ResourceTelemetrySourceHealth,
335 desktop: ResourceTelemetrySourceHealth,
336 sidecarVersion: Schema.Option(TrimmedNonEmptyString),
337 sidecarPid: Schema.Option(PositiveInt),
338 restartCount: NonNegativeInt,
339 collectionDurationMicros: NonNegativeInt,
340 scannedProcessCount: NonNegativeInt,
341 retainedProcessCount: NonNegativeInt,
342 inaccessibleProcessCount: NonNegativeInt,
343});
344export type ResourceTelemetryHealth = typeof ResourceTelemetryHealth.Type;
345
346export const ResourceAttributionEntry = Schema.Struct({
347 component: TrimmedNonEmptyString,
348 operation: TrimmedNonEmptyString,
349 logicalReadBytes: NonNegativeInt,
350 logicalWriteBytes: NonNegativeInt,
351 count: NonNegativeInt,
352 durationMs: NonNegativeInt,
353});
354export type ResourceAttributionEntry = typeof ResourceAttributionEntry.Type;
355
356export const ResourceAttributionSnapshot = Schema.Struct({
357 readAt: Schema.DateTimeUtc,
358 entries: Schema.Array(ResourceAttributionEntry),
359});
360export type ResourceAttributionSnapshot = typeof ResourceAttributionSnapshot.Type;
361
362export const ResourceTelemetrySnapshot = Schema.Struct({
363 readAt: Schema.DateTimeUtc,
364 sampleIntervalMs: NonNegativeInt,
365 processes: Schema.Array(ResourceTelemetryProcess),
366 groups: ResourceTelemetryGroups,
367 power: HostPowerSnapshot,
368 speedLimitPercent: Schema.Option(Schema.Number),
369 attribution: ResourceAttributionSnapshot,
370 health: ResourceTelemetryHealth,
371});
372export type ResourceTelemetrySnapshot = typeof ResourceTelemetrySnapshot.Type;
373
374export const ResourceTelemetryHistoryInput = Schema.Struct({
375 windowMs: NonNegativeInt,
376 bucketMs: NonNegativeInt,
377});
378export type ResourceTelemetryHistoryInput = typeof ResourceTelemetryHistoryInput.Type;
379
380export const ResourceTelemetryHistoryBucket = Schema.Struct({
381 startedAt: Schema.DateTimeUtc,
382 endedAt: Schema.DateTimeUtc,
383 avgCpuPercent: Schema.Number,
384 maxCpuPercent: Schema.Number,
385 maxRssBytes: NonNegativeInt,
386 ioReadBytes: NonNegativeInt,
387 ioWriteBytes: NonNegativeInt,
388 maxProcessCount: NonNegativeInt,
389});
390export type ResourceTelemetryHistoryBucket = typeof ResourceTelemetryHistoryBucket.Type;
391
392export const ResourceTelemetryProcessSummary = Schema.Struct({
393 identity: ResourceTelemetryProcessIdentity,
394 ppid: NonNegativeInt,
395 depth: NonNegativeInt,
396 name: Schema.String,
397 command: Schema.String,
398 category: ResourceTelemetryProcessCategory,
399 firstSeenAt: Schema.DateTimeUtc,
400 lastSeenAt: Schema.DateTimeUtc,
401 currentCpuPercent: Schema.Number,
402 avgCpuPercent: Schema.Number,
403 maxCpuPercent: Schema.Number,
404 cpuTimeMs: NonNegativeInt,
405 currentRssBytes: NonNegativeInt,
406 peakRssBytes: NonNegativeInt,
407 ioReadBytes: NonNegativeInt,
408 ioWriteBytes: NonNegativeInt,
409 ioSemantics: ResourceTelemetryIoSemantics,
410 sampleCount: NonNegativeInt,
411});
412export type ResourceTelemetryProcessSummary = typeof ResourceTelemetryProcessSummary.Type;
413
414export const ResourceTelemetryHistory = Schema.Struct({
415 readAt: Schema.DateTimeUtc,
416 windowMs: NonNegativeInt,
417 bucketMs: NonNegativeInt,
418 sampleIntervalMs: NonNegativeInt,
419 retainedSampleCount: NonNegativeInt,
420 buckets: Schema.Array(ResourceTelemetryHistoryBucket),
421 topProcesses: Schema.Array(ResourceTelemetryProcessSummary),
422 health: ResourceTelemetryHealth,
423});
424export type ResourceTelemetryHistory = typeof ResourceTelemetryHistory.Type;
425
426export const ResourceTelemetryRetryResult = Schema.Struct({
427 accepted: Schema.Boolean,
428 snapshot: ResourceTelemetrySnapshot,
429});
430export type ResourceTelemetryRetryResult = typeof ResourceTelemetryRetryResult.Type;
packages/contracts/src/rpc.ts+70
......@@ -8,6 +8,11 @@ import {
88 AuthAccessStreamEvent,
99 EnvironmentAuthorizationError,
1010} from "./auth.ts";
11import {
12 BackgroundPolicySnapshot,
13 ClientActivityReportInput,
14 HostPowerSnapshot,
15} from "./background.ts";
1116import {
1217 FilesystemBrowseInput,
1318 FilesystemBrowseResult,
......@@ -132,6 +137,12 @@ import {
132137 ServerUpsertKeybindingInput,
133138 ServerUpsertKeybindingResult,
134139} from "./server.ts";
140import {
141 ResourceTelemetryHistory,
142 ResourceTelemetryHistoryInput,
143 ResourceTelemetryRetryResult,
144 ResourceTelemetrySnapshot,
145} from "./resourceTelemetry.ts";
135146import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts";
136147import {
137148 SourceControlCloneRepositoryInput,
......@@ -215,7 +226,12 @@ export const WS_METHODS = {
215226 serverGetTraceDiagnostics: "server.getTraceDiagnostics",
216227 serverGetProcessDiagnostics: "server.getProcessDiagnostics",
217228 serverGetProcessResourceHistory: "server.getProcessResourceHistory",
229 serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory",
230 serverRetryResourceTelemetry: "server.retryResourceTelemetry",
218231 serverSignalProcess: "server.signalProcess",
232 serverReportClientActivity: "server.reportClientActivity",
233 serverReportHostPowerState: "server.reportHostPowerState",
234 serverGetBackgroundPolicy: "server.getBackgroundPolicy",
219235
220236 // Cloud environment methods
221237 cloudGetRelayClientStatus: "cloud.getRelayClientStatus",
......@@ -235,6 +251,8 @@ export const WS_METHODS = {
235251 subscribeServerConfig: "subscribeServerConfig",
236252 subscribeServerLifecycle: "subscribeServerLifecycle",
237253 subscribeAuthAccess: "subscribeAuthAccess",
254 subscribeBackgroundPolicy: "subscribeBackgroundPolicy",
255 subscribeResourceTelemetry: "subscribeResourceTelemetry",
238256} as const;
239257
240258export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, {
......@@ -326,6 +344,21 @@ export const WsServerGetProcessResourceHistoryRpc = Rpc.make(
326344 },
327345);
328346
347export const WsServerGetResourceTelemetryHistoryRpc = Rpc.make(
348 WS_METHODS.serverGetResourceTelemetryHistory,
349 {
350 payload: ResourceTelemetryHistoryInput,
351 success: ResourceTelemetryHistory,
352 error: EnvironmentAuthorizationError,
353 },
354);
355
356export const WsServerRetryResourceTelemetryRpc = Rpc.make(WS_METHODS.serverRetryResourceTelemetry, {
357 payload: Schema.Struct({}),
358 success: ResourceTelemetryRetryResult,
359 error: EnvironmentAuthorizationError,
360});
361
329362export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, {
330363 payload: ServerSignalProcessInput,
331364 success: ServerSignalProcessResult,
......@@ -345,6 +378,22 @@ export const WsCloudInstallRelayClientRpc = Rpc.make(WS_METHODS.cloudInstallRela
345378 stream: true,
346379});
347380
381export const WsServerReportClientActivityRpc = Rpc.make(WS_METHODS.serverReportClientActivity, {
382 payload: ClientActivityReportInput,
383 error: EnvironmentAuthorizationError,
384});
385
386export const WsServerReportHostPowerStateRpc = Rpc.make(WS_METHODS.serverReportHostPowerState, {
387 payload: HostPowerSnapshot,
388 error: EnvironmentAuthorizationError,
389});
390
391export const WsServerGetBackgroundPolicyRpc = Rpc.make(WS_METHODS.serverGetBackgroundPolicy, {
392 payload: Schema.Struct({}),
393 success: BackgroundPolicySnapshot,
394 error: EnvironmentAuthorizationError,
395});
396
348397export const WsSourceControlLookupRepositoryRpc = Rpc.make(
349398 WS_METHODS.sourceControlLookupRepository,
350399 {
......@@ -690,6 +739,20 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess,
690739 stream: true,
691740});
692741
742export const WsSubscribeBackgroundPolicyRpc = Rpc.make(WS_METHODS.subscribeBackgroundPolicy, {
743 payload: Schema.Struct({}),
744 success: BackgroundPolicySnapshot,
745 error: EnvironmentAuthorizationError,
746 stream: true,
747});
748
749export const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeResourceTelemetry, {
750 payload: Schema.Struct({}),
751 success: ResourceTelemetrySnapshot,
752 error: EnvironmentAuthorizationError,
753 stream: true,
754});
755
693756export const WsRpcGroup = RpcGroup.make(
694757 WsServerProbeRpc,
695758 WsServerGetConfigRpc,
......@@ -704,7 +767,12 @@ export const WsRpcGroup = RpcGroup.make(
704767 WsServerGetTraceDiagnosticsRpc,
705768 WsServerGetProcessDiagnosticsRpc,
706769 WsServerGetProcessResourceHistoryRpc,
770 WsServerGetResourceTelemetryHistoryRpc,
771 WsServerRetryResourceTelemetryRpc,
707772 WsServerSignalProcessRpc,
773 WsServerReportClientActivityRpc,
774 WsServerReportHostPowerStateRpc,
775 WsServerGetBackgroundPolicyRpc,
708776 WsCloudGetRelayClientStatusRpc,
709777 WsCloudInstallRelayClientRpc,
710778 WsSourceControlLookupRepositoryRpc,
......@@ -754,6 +822,8 @@ export const WsRpcGroup = RpcGroup.make(
754822 WsSubscribeServerConfigRpc,
755823 WsSubscribeServerLifecycleRpc,
756824 WsSubscribeAuthAccessRpc,
825 WsSubscribeBackgroundPolicyRpc,
826 WsSubscribeResourceTelemetryRpc,
757827 WsOrchestrationDispatchCommandRpc,
758828 WsOrchestrationGetTurnDiffRpc,
759829 WsOrchestrationGetFullThreadDiffRpc,
packages/contracts/src/server.ts+2
......@@ -303,6 +303,7 @@ export type ServerProcessSignal = typeof ServerProcessSignal.Type;
303303
304304export const ServerProcessDiagnosticsEntry = Schema.Struct({
305305 pid: PositiveInt,
306 startTimeMs: NonNegativeInt,
306307 ppid: NonNegativeInt,
307308 pgid: Schema.Option(Schema.Int),
308309 status: TrimmedNonEmptyString,
......@@ -395,6 +396,7 @@ export type ServerProcessResourceHistoryResult = typeof ServerProcessResourceHis
395396
396397export const ServerSignalProcessInput = Schema.Struct({
397398 pid: PositiveInt,
399 startTimeMs: NonNegativeInt,
398400 signal: ServerProcessSignal,
399401});
400402export type ServerSignalProcessInput = typeof ServerSignalProcessInput.Type;
packages/contracts/src/settings.ts+61
......@@ -426,15 +426,66 @@ export const SourceControlWritingStyleSettings = Schema.Struct({
426426export type SourceControlWritingStyleSettings = typeof SourceControlWritingStyleSettings.Type;
427427
428428export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30);
429export const DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL = Duration.minutes(5);
430
431export const BackgroundActivityProfile = Schema.Literals([
432 "balanced",
433 "performance",
434 "battery-saver",
435]);
436export type BackgroundActivityProfile = typeof BackgroundActivityProfile.Type;
437export const DEFAULT_BACKGROUND_ACTIVITY_PROFILE: BackgroundActivityProfile = "balanced";
438
439export const BackgroundActivityProfileSelection = Schema.Literals([
440 "balanced",
441 "performance",
442 "battery-saver",
443 "custom",
444]);
445export type BackgroundActivityProfileSelection = typeof BackgroundActivityProfileSelection.Type;
446
447export const BackgroundActivityOverrides = Schema.Struct({
448 automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis),
449 providerHealthRefreshInterval: Schema.optionalKey(Schema.DurationFromMillis),
450 hostPowerMonitorActiveInterval: Schema.optionalKey(Schema.DurationFromMillis),
451 hostPowerMonitorIdleInterval: Schema.optionalKey(Schema.DurationFromMillis),
452 idleClientTtl: Schema.optionalKey(Schema.DurationFromMillis),
453 pauseWhenHostLocked: Schema.optionalKey(Schema.Boolean),
454 pauseWhenHostLowPower: Schema.optionalKey(Schema.Boolean),
455 pauseWhenClientLowPower: Schema.optionalKey(Schema.Boolean),
456 pauseWhenOnBattery: Schema.optionalKey(Schema.Boolean),
457});
458export type BackgroundActivityOverrides = typeof BackgroundActivityOverrides.Type;
459
460export const BackgroundActivitySettings = Schema.Struct({
461 schemaVersion: Schema.Literal(1).pipe(Schema.withDecodingDefault(Effect.succeed(1 as const))),
462 profile: BackgroundActivityProfileSelection.pipe(
463 Schema.withDecodingDefault(Effect.succeed(DEFAULT_BACKGROUND_ACTIVITY_PROFILE)),
464 ),
465 baseProfile: Schema.optionalKey(BackgroundActivityProfile),
466 overrides: BackgroundActivityOverrides.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
467}).pipe(Schema.withDecodingDefault(Effect.succeed({})));
468export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type;
429469
430470export const ServerSettings = Schema.Struct({
431471 enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
432472 enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
473 backgroundActivity: BackgroundActivitySettings,
474 // Legacy flat fields retained for old settings files and old clients. New
475 // consumers should resolve `backgroundActivity` instead.
433476 automaticGitFetchInterval: Schema.DurationFromMillis.pipe(
434477 Schema.withDecodingDefault(
435478 Effect.succeed(Duration.toMillis(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)),
436479 ),
437480 ),
481 providerHealthRefreshInterval: Schema.DurationFromMillis.pipe(
482 Schema.withDecodingDefault(
483 Effect.succeed(Duration.toMillis(DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL)),
484 ),
485 ),
486 backgroundActivityProfile: BackgroundActivityProfile.pipe(
487 Schema.withDecodingDefault(Effect.succeed(DEFAULT_BACKGROUND_ACTIVITY_PROFILE)),
488 ),
438489 defaultThreadEnvMode: ThreadEnvMode.pipe(
439490 Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)),
440491 ),
......@@ -576,7 +627,17 @@ export const ServerSettingsPatch = Schema.Struct({
576627 // Server settings
577628 enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),
578629 enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),
630 backgroundActivity: Schema.optionalKey(
631 Schema.Struct({
632 schemaVersion: Schema.optionalKey(Schema.Literal(1)),
633 profile: Schema.optionalKey(BackgroundActivityProfileSelection),
634 baseProfile: Schema.optionalKey(BackgroundActivityProfile),
635 overrides: Schema.optionalKey(BackgroundActivityOverrides),
636 }),
637 ),
579638 automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis),
639 providerHealthRefreshInterval: Schema.optionalKey(Schema.DurationFromMillis),
640 backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile),
580641 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
581642 newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),
582643 addProjectBaseDirectory: Schema.optionalKey(TrimmedString),
packages/shared/package.json+4
......@@ -79,6 +79,10 @@
7979 "types": "./src/serverSettings.ts",
8080 "import": "./src/serverSettings.ts"
8181 },
82 "./backgroundActivitySettings": {
83 "types": "./src/backgroundActivitySettings.ts",
84 "import": "./src/backgroundActivitySettings.ts"
85 },
8286 "./String": {
8387 "types": "./src/String.ts",
8488 "import": "./src/String.ts"
packages/shared/src/backgroundActivitySettings.ts created+270
......@@ -0,0 +1,270 @@
1import {
2 type BackgroundActivityProfile,
3 type BackgroundActivitySettings,
4 DEFAULT_BACKGROUND_ACTIVITY_PROFILE,
5 DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL,
6 DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL,
7 type ServerSettings,
8} from "@t3tools/contracts";
9import * as Duration from "effect/Duration";
10
11export interface ResolvedBackgroundActivitySettings {
12 readonly profile: BackgroundActivityProfile;
13 readonly automaticGitFetchInterval: Duration.Duration;
14 readonly providerHealthRefreshInterval: Duration.Duration;
15 readonly hostPowerMonitorActiveInterval: Duration.Duration;
16 readonly hostPowerMonitorIdleInterval: Duration.Duration;
17 readonly idleClientTtl: Duration.Duration;
18 readonly pauseWhenHostLocked: boolean;
19 readonly pauseWhenHostLowPower: boolean;
20 readonly pauseWhenClientLowPower: boolean;
21 readonly pauseWhenOnBattery: boolean;
22}
23
24const PRESET_SETTINGS: Record<BackgroundActivityProfile, ResolvedBackgroundActivitySettings> = {
25 performance: {
26 profile: "performance",
27 automaticGitFetchInterval: Duration.seconds(15),
28 providerHealthRefreshInterval: Duration.minutes(1),
29 hostPowerMonitorActiveInterval: Duration.seconds(30),
30 hostPowerMonitorIdleInterval: Duration.minutes(2),
31 idleClientTtl: Duration.seconds(45),
32 pauseWhenHostLocked: true,
33 pauseWhenHostLowPower: false,
34 pauseWhenClientLowPower: false,
35 pauseWhenOnBattery: false,
36 },
37 balanced: {
38 profile: "balanced",
39 automaticGitFetchInterval: DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL,
40 providerHealthRefreshInterval: DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL,
41 hostPowerMonitorActiveInterval: Duration.seconds(30),
42 hostPowerMonitorIdleInterval: Duration.minutes(5),
43 idleClientTtl: Duration.seconds(45),
44 pauseWhenHostLocked: true,
45 pauseWhenHostLowPower: true,
46 pauseWhenClientLowPower: true,
47 pauseWhenOnBattery: false,
48 },
49 "battery-saver": {
50 profile: "battery-saver",
51 automaticGitFetchInterval: Duration.seconds(0),
52 providerHealthRefreshInterval: Duration.minutes(15),
53 hostPowerMonitorActiveInterval: Duration.minutes(1),
54 hostPowerMonitorIdleInterval: Duration.minutes(10),
55 idleClientTtl: Duration.seconds(45),
56 pauseWhenHostLocked: true,
57 pauseWhenHostLowPower: true,
58 pauseWhenClientLowPower: true,
59 pauseWhenOnBattery: true,
60 },
61};
62
63export function getBackgroundActivityPresetSettings(
64 profile: BackgroundActivityProfile,
65): ResolvedBackgroundActivitySettings {
66 return PRESET_SETTINGS[profile];
67}
68
69export function getBackgroundActivityBaseProfile(
70 backgroundActivity: BackgroundActivitySettings,
71): BackgroundActivityProfile {
72 if (backgroundActivity.profile === "custom") {
73 return backgroundActivity.baseProfile ?? DEFAULT_BACKGROUND_ACTIVITY_PROFILE;
74 }
75 return backgroundActivity.profile;
76}
77
78export function resolveBackgroundActivitySettings(
79 backgroundActivity: BackgroundActivitySettings,
80): ResolvedBackgroundActivitySettings {
81 const baseProfile = getBackgroundActivityBaseProfile(backgroundActivity);
82 const preset = PRESET_SETTINGS[baseProfile];
83 const overrides = backgroundActivity.profile === "custom" ? backgroundActivity.overrides : {};
84 return {
85 profile: baseProfile,
86 automaticGitFetchInterval:
87 overrides.automaticGitFetchInterval ?? preset.automaticGitFetchInterval,
88 providerHealthRefreshInterval:
89 overrides.providerHealthRefreshInterval ?? preset.providerHealthRefreshInterval,
90 hostPowerMonitorActiveInterval:
91 overrides.hostPowerMonitorActiveInterval ?? preset.hostPowerMonitorActiveInterval,
92 hostPowerMonitorIdleInterval:
93 overrides.hostPowerMonitorIdleInterval ?? preset.hostPowerMonitorIdleInterval,
94 idleClientTtl: overrides.idleClientTtl ?? preset.idleClientTtl,
95 pauseWhenHostLocked: overrides.pauseWhenHostLocked ?? preset.pauseWhenHostLocked,
96 pauseWhenHostLowPower: overrides.pauseWhenHostLowPower ?? preset.pauseWhenHostLowPower,
97 pauseWhenClientLowPower: overrides.pauseWhenClientLowPower ?? preset.pauseWhenClientLowPower,
98 pauseWhenOnBattery: overrides.pauseWhenOnBattery ?? preset.pauseWhenOnBattery,
99 };
100}
101
102function durationsEqual(a: Duration.Duration, b: Duration.Duration): boolean {
103 return Duration.toMillis(a) === Duration.toMillis(b);
104}
105
106function resolvedSettingsEqual(
107 a: ResolvedBackgroundActivitySettings,
108 b: ResolvedBackgroundActivitySettings,
109): boolean {
110 return (
111 durationsEqual(a.automaticGitFetchInterval, b.automaticGitFetchInterval) &&
112 durationsEqual(a.providerHealthRefreshInterval, b.providerHealthRefreshInterval) &&
113 durationsEqual(a.hostPowerMonitorActiveInterval, b.hostPowerMonitorActiveInterval) &&
114 durationsEqual(a.hostPowerMonitorIdleInterval, b.hostPowerMonitorIdleInterval) &&
115 durationsEqual(a.idleClientTtl, b.idleClientTtl) &&
116 a.pauseWhenHostLocked === b.pauseWhenHostLocked &&
117 a.pauseWhenHostLowPower === b.pauseWhenHostLowPower &&
118 a.pauseWhenClientLowPower === b.pauseWhenClientLowPower &&
119 a.pauseWhenOnBattery === b.pauseWhenOnBattery
120 );
121}
122
123export function normalizeBackgroundActivitySettings(
124 backgroundActivity: BackgroundActivitySettings,
125): BackgroundActivitySettings {
126 if (backgroundActivity.profile !== "custom") {
127 return {
128 schemaVersion: 1,
129 profile: backgroundActivity.profile,
130 overrides: {},
131 };
132 }
133
134 const resolved = resolveBackgroundActivitySettings(backgroundActivity);
135 const profiles: ReadonlyArray<BackgroundActivityProfile> = [
136 getBackgroundActivityBaseProfile(backgroundActivity),
137 "balanced",
138 "performance",
139 "battery-saver",
140 ];
141 for (const profile of profiles) {
142 if (resolvedSettingsEqual(resolved, PRESET_SETTINGS[profile])) {
143 return {
144 schemaVersion: 1,
145 profile,
146 overrides: {},
147 };
148 }
149 }
150
151 const baseProfile = getBackgroundActivityBaseProfile(backgroundActivity);
152 const preset = PRESET_SETTINGS[baseProfile];
153 const overrides: BackgroundActivitySettings["overrides"] = {
154 ...(!durationsEqual(resolved.automaticGitFetchInterval, preset.automaticGitFetchInterval)
155 ? { automaticGitFetchInterval: resolved.automaticGitFetchInterval }
156 : {}),
157 ...(!durationsEqual(
158 resolved.providerHealthRefreshInterval,
159 preset.providerHealthRefreshInterval,
160 )
161 ? { providerHealthRefreshInterval: resolved.providerHealthRefreshInterval }
162 : {}),
163 ...(!durationsEqual(
164 resolved.hostPowerMonitorActiveInterval,
165 preset.hostPowerMonitorActiveInterval,
166 )
167 ? { hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval }
168 : {}),
169 ...(!durationsEqual(resolved.hostPowerMonitorIdleInterval, preset.hostPowerMonitorIdleInterval)
170 ? { hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval }
171 : {}),
172 ...(!durationsEqual(resolved.idleClientTtl, preset.idleClientTtl)
173 ? { idleClientTtl: resolved.idleClientTtl }
174 : {}),
175 ...(resolved.pauseWhenHostLocked !== preset.pauseWhenHostLocked
176 ? { pauseWhenHostLocked: resolved.pauseWhenHostLocked }
177 : {}),
178 ...(resolved.pauseWhenHostLowPower !== preset.pauseWhenHostLowPower
179 ? { pauseWhenHostLowPower: resolved.pauseWhenHostLowPower }
180 : {}),
181 ...(resolved.pauseWhenClientLowPower !== preset.pauseWhenClientLowPower
182 ? { pauseWhenClientLowPower: resolved.pauseWhenClientLowPower }
183 : {}),
184 ...(resolved.pauseWhenOnBattery !== preset.pauseWhenOnBattery
185 ? { pauseWhenOnBattery: resolved.pauseWhenOnBattery }
186 : {}),
187 };
188
189 return {
190 schemaVersion: 1,
191 profile: "custom",
192 baseProfile,
193 overrides,
194 };
195}
196
197export function resolveServerBackgroundActivitySettings(
198 settings: ServerSettings,
199): ResolvedBackgroundActivitySettings {
200 const defaultBackgroundActivity: BackgroundActivitySettings = {
201 schemaVersion: 1,
202 profile: DEFAULT_BACKGROUND_ACTIVITY_PROFILE,
203 overrides: {},
204 };
205 const backgroundActivityIsDefault =
206 settings.backgroundActivity.profile === defaultBackgroundActivity.profile &&
207 settings.backgroundActivity.baseProfile === undefined &&
208 Object.keys(settings.backgroundActivity.overrides).length === 0;
209 const legacyProfile = settings.backgroundActivityProfile;
210 const hasLegacyOverrides =
211 legacyProfile !== DEFAULT_BACKGROUND_ACTIVITY_PROFILE ||
212 Duration.toMillis(settings.automaticGitFetchInterval) !==
213 Duration.toMillis(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL) ||
214 Duration.toMillis(settings.providerHealthRefreshInterval) !==
215 Duration.toMillis(DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL);
216 if (backgroundActivityIsDefault && hasLegacyOverrides) {
217 return resolveBackgroundActivitySettings({
218 schemaVersion: 1,
219 profile:
220 Duration.toMillis(settings.automaticGitFetchInterval) ===
221 Duration.toMillis(
222 getBackgroundActivityPresetSettings(legacyProfile).automaticGitFetchInterval,
223 ) &&
224 Duration.toMillis(settings.providerHealthRefreshInterval) ===
225 Duration.toMillis(
226 getBackgroundActivityPresetSettings(legacyProfile).providerHealthRefreshInterval,
227 )
228 ? legacyProfile
229 : "custom",
230 baseProfile: legacyProfile,
231 overrides: {
232 ...(Duration.toMillis(settings.automaticGitFetchInterval) !==
233 Duration.toMillis(
234 getBackgroundActivityPresetSettings(legacyProfile).automaticGitFetchInterval,
235 )
236 ? { automaticGitFetchInterval: settings.automaticGitFetchInterval }
237 : {}),
238 ...(Duration.toMillis(settings.providerHealthRefreshInterval) !==
239 Duration.toMillis(
240 getBackgroundActivityPresetSettings(legacyProfile).providerHealthRefreshInterval,
241 )
242 ? { providerHealthRefreshInterval: settings.providerHealthRefreshInterval }
243 : {}),
244 },
245 });
246 }
247 return resolveBackgroundActivitySettings(settings.backgroundActivity);
248}
249
250export function normalizeServerBackgroundActivitySettings(
251 settings: ServerSettings,
252): BackgroundActivitySettings {
253 const resolved = resolveServerBackgroundActivitySettings(settings);
254 return normalizeBackgroundActivitySettings({
255 schemaVersion: 1,
256 profile: "custom",
257 baseProfile: resolved.profile,
258 overrides: {
259 automaticGitFetchInterval: resolved.automaticGitFetchInterval,
260 providerHealthRefreshInterval: resolved.providerHealthRefreshInterval,
261 hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval,
262 hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval,
263 idleClientTtl: resolved.idleClientTtl,
264 pauseWhenHostLocked: resolved.pauseWhenHostLocked,
265 pauseWhenHostLowPower: resolved.pauseWhenHostLowPower,
266 pauseWhenClientLowPower: resolved.pauseWhenClientLowPower,
267 pauseWhenOnBattery: resolved.pauseWhenOnBattery,
268 },
269 });
270}
packages/shared/src/logging.test.ts+19
......@@ -127,6 +127,25 @@ describe("RotatingFileSink", () => {
127127 expect((thrown as RotatingFileSinkError).cause).toBeInstanceOf(Error);
128128 });
129129
130 it("never reports a rotation failure after successfully appending a chunk", () => {
131 const directory = makeTempDirectory();
132 const filePath = NodePath.join(directory, "log.ndjson");
133 NodeFS.mkdirSync(`${filePath}.1`);
134 const sink = new RotatingFileSink({
135 filePath,
136 maxBytes: 1,
137 maxFiles: 1,
138 throwOnError: true,
139 });
140
141 sink.write("oversized");
142
143 expect(NodeFS.readFileSync(filePath, "utf8")).toBe("oversized");
144 const thrown = captureError(() => sink.write("next"));
145 expect(thrown).toMatchObject({ operation: "rotate", filePath });
146 expect(NodeFS.readFileSync(filePath, "utf8")).toBe("oversized");
147 });
148
130149 it("preserves backup pruning failures", () => {
131150 const directory = makeTempDirectory();
132151 const filePath = NodePath.join(directory, "log.ndjson");
packages/shared/src/logging.ts-4
......@@ -93,10 +93,6 @@ export class RotatingFileSink {
9393
9494 NodeFS.appendFileSync(this.filePath, buffer);
9595 this.currentSize += buffer.length;
96
97 if (this.currentSize > this.maxBytes) {
98 this.rotate();
99 }
10096 } catch (cause) {
10197 if (isRotatingFileSinkError(cause)) {
10298 throw cause;
packages/shared/src/observability.test.ts+95-1
......@@ -8,6 +8,7 @@ import * as Layer from "effect/Layer";
88import * as Logger from "effect/Logger";
99import * as Order from "effect/Order";
1010import * as Path from "effect/Path";
11import * as Ref from "effect/Ref";
1112import * as References from "effect/References";
1213import * as Schema from "effect/Schema";
1314import * as Tracer from "effect/Tracer";
......@@ -19,6 +20,7 @@ import {
1920 makeLocalFileTracer,
2021 makeTraceSink,
2122 type TraceRecord,
23 type TraceSinkFlushStats,
2224} from "./observability.ts";
2325
2426describe("errorTag", () => {
......@@ -176,6 +178,34 @@ describe("observability", () => {
176178 ),
177179 );
178180
181 it.effect("reports successful logical trace writes", () =>
182 Effect.scoped(
183 Effect.gen(function* () {
184 const fileSystem = yield* FileSystem.FileSystem;
185 const path = yield* Path.Path;
186 const tempDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-trace-sink-" });
187 const tracePath = path.join(tempDir, "shared.trace.ndjson");
188 const reported = yield* Ref.make<ReadonlyArray<TraceSinkFlushStats>>([]);
189
190 const sink = yield* makeTraceSink({
191 filePath: tracePath,
192 maxBytes: 1024,
193 maxFiles: 2,
194 batchWindowMs: 10_000,
195 onFlush: (stats) => Ref.update(reported, (current) => [...current, stats]),
196 });
197
198 sink.push(makeRecord("attributed"));
199 yield* sink.flush;
200
201 const stats = yield* Ref.get(reported);
202 assert.equal(stats.length, 1);
203 assert.equal(stats[0]?.count, 1);
204 assert.isAbove(stats[0]?.logicalWriteBytes ?? 0, 0);
205 }),
206 ),
207 );
208
179209 it.effect("rotates the trace file when the configured max size is exceeded", () =>
180210 Effect.scoped(
181211 Effect.gen(function* () {
......@@ -186,7 +216,7 @@ describe("observability", () => {
186216
187217 const sink = yield* makeTraceSink({
188218 filePath: tracePath,
189 maxBytes: 180,
219 maxBytes: 500,
190220 maxFiles: 2,
191221 batchWindowMs: 10_000,
192222 });
......@@ -217,6 +247,70 @@ describe("observability", () => {
217247 ),
218248 );
219249
250 it.effect("keeps every trace file within the configured limit for threshold flushes", () =>
251 Effect.scoped(
252 Effect.gen(function* () {
253 const fileSystem = yield* FileSystem.FileSystem;
254 const path = yield* Path.Path;
255 const tempDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-trace-sink-" });
256 const tracePath = path.join(tempDir, "shared.trace.ndjson");
257 const maxBytes = 1_024;
258
259 const sink = yield* makeTraceSink({
260 filePath: tracePath,
261 maxBytes,
262 maxFiles: 2,
263 batchWindowMs: 10_000,
264 });
265
266 for (let index = 0; index < 256; index += 1) {
267 sink.push(makeRecord("threshold", `${index}-${"x".repeat(48)}`));
268 }
269 yield* sink.close();
270
271 const matchingFiles = (yield* fileSystem.readDirectory(tempDir)).filter(
272 (entry) => entry === "shared.trace.ndjson" || entry.startsWith("shared.trace.ndjson."),
273 );
274 assert.include(matchingFiles, "shared.trace.ndjson.1");
275 for (const entry of matchingFiles) {
276 const stat = yield* fileSystem.stat(path.join(tempDir, entry));
277 assert.isAtMost(Number(stat.size), maxBytes, entry);
278 }
279 }),
280 ),
281 );
282
283 it.effect("drops a single trace record that cannot fit within the configured limit", () =>
284 Effect.scoped(
285 Effect.gen(function* () {
286 const fileSystem = yield* FileSystem.FileSystem;
287 const path = yield* Path.Path;
288 const tempDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-trace-sink-" });
289 const tracePath = path.join(tempDir, "shared.trace.ndjson");
290 const maxBytes = 1_024;
291
292 const sink = yield* makeTraceSink({
293 filePath: tracePath,
294 maxBytes,
295 maxFiles: 2,
296 batchWindowMs: 10_000,
297 });
298
299 sink.push(makeRecord("oversized", "x".repeat(maxBytes * 2)));
300 sink.push(makeRecord("retained"));
301 yield* sink.close();
302
303 const records = yield* readTraceRecords(tracePath);
304 const stat = yield* fileSystem.stat(tracePath);
305 assert.deepEqual(
306 records.map((record) => record.name),
307 ["retained"],
308 );
309 assert.isAtMost(Number(stat.size), maxBytes);
310 }),
311 ),
312 );
313
220314 it.effect("drops only the invalid trace record when serialization fails", () =>
221315 Effect.scoped(
222316 Effect.gen(function* () {
packages/shared/src/observability.ts+63-7
......@@ -8,7 +8,8 @@ import { OtlpResource, OtlpTracer } from "effect/unstable/observability";
88
99import { RotatingFileSink } from "./logging.ts";
1010
11const FLUSH_BUFFER_THRESHOLD = 32;
11const FLUSH_BUFFER_THRESHOLD = 256;
12const textEncoder = new TextEncoder();
1213
1314export type TraceAttributes = Readonly<Record<string, unknown>>;
1415
......@@ -109,6 +110,13 @@ export interface TraceSinkOptions {
109110 readonly maxBytes: number;
110111 readonly maxFiles: number;
111112 readonly batchWindowMs: number;
113 readonly onFlush?: (stats: TraceSinkFlushStats) => Effect.Effect<void>;
114}
115
116export interface TraceSinkFlushStats {
117 readonly logicalWriteBytes: number;
118 readonly count: number;
119 readonly durationMs: number;
112120}
113121
114122export interface TraceSink {
......@@ -275,26 +283,73 @@ export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: Trac
275283 filePath: options.filePath,
276284 maxBytes: options.maxBytes,
277285 maxFiles: options.maxFiles,
286 throwOnError: true,
278287 });
279288
280289 let buffer: Array<string> = [];
290 let pendingFlushStats: TraceSinkFlushStats = {
291 logicalWriteBytes: 0,
292 count: 0,
293 durationMs: 0,
294 };
281295
282296 const flushUnsafe = () => {
283297 if (buffer.length === 0) {
284298 return;
285299 }
286300
287 const chunk = buffer.join("");
301 const records = buffer;
288302 buffer = [];
303 let persistedCount = 0;
304
305 while (persistedCount < records.length) {
306 const firstRecordBytes = textEncoder.encode(records[persistedCount]).byteLength;
307 if (firstRecordBytes > options.maxBytes) {
308 persistedCount += 1;
309 continue;
310 }
311
312 let nextIndex = persistedCount + 1;
313 let chunkBytes = firstRecordBytes;
314 while (nextIndex < records.length) {
315 const nextRecordBytes = textEncoder.encode(records[nextIndex]).byteLength;
316 if (chunkBytes + nextRecordBytes > options.maxBytes) break;
317 chunkBytes += nextRecordBytes;
318 nextIndex += 1;
319 }
289320
290 try {
291 sink.write(chunk);
292 } catch {
293 buffer.unshift(chunk);
321 const chunk = records.slice(persistedCount, nextIndex).join("");
322 const startedAt = performance.now();
323 try {
324 sink.write(chunk);
325 } catch {
326 buffer.unshift(...records.slice(persistedCount));
327 return;
328 }
329 pendingFlushStats = {
330 logicalWriteBytes: pendingFlushStats.logicalWriteBytes + chunkBytes,
331 count: pendingFlushStats.count + nextIndex - persistedCount,
332 durationMs: pendingFlushStats.durationMs + Math.max(0, performance.now() - startedAt),
333 };
334 persistedCount = nextIndex;
294335 }
295336 };
296337
297 const flush = Effect.sync(flushUnsafe).pipe(Effect.withTracerEnabled(false));
338 const flush = Effect.sync(() => {
339 flushUnsafe();
340 const stats = pendingFlushStats;
341 pendingFlushStats = {
342 logicalWriteBytes: 0,
343 count: 0,
344 durationMs: 0,
345 };
346 return stats;
347 }).pipe(
348 Effect.flatMap((stats) =>
349 stats.count > 0 && options.onFlush ? options.onFlush(stats).pipe(Effect.ignore) : Effect.void,
350 ),
351 Effect.withTracerEnabled(false),
352 );
298353
299354 yield* Effect.addFinalizer(() => flush.pipe(Effect.ignore));
300355 yield* Effect.forkScoped(
......@@ -399,6 +454,7 @@ export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* (
399454 maxBytes: options.maxBytes,
400455 maxFiles: options.maxFiles,
401456 batchWindowMs: options.batchWindowMs,
457 ...(options.onFlush ? { onFlush: options.onFlush } : {}),
402458 }));
403459
404460 const delegate =
packages/shared/src/serverSettings.test.ts+217
......@@ -4,7 +4,9 @@ import {
44 ProviderInstanceId,
55 type ServerProvider,
66} from "@t3tools/contracts";
7import * as Duration from "effect/Duration";
78import { describe, expect, it } from "vite-plus/test";
9import { resolveServerBackgroundActivitySettings } from "./backgroundActivitySettings.ts";
810import { createModelSelection } from "./model.ts";
911import {
1012 applyServerSettingsPatch,
......@@ -295,4 +297,219 @@ describe("serverSettings helpers", () => {
295297 config: { homePath: "~/.codex" },
296298 });
297299 });
300
301 it("stores background activity profiles as a versioned object and syncs legacy aliases", () => {
302 const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
303 backgroundActivity: {
304 schemaVersion: 1,
305 profile: "battery-saver",
306 overrides: {},
307 },
308 });
309
310 expect(next.backgroundActivity).toEqual({
311 schemaVersion: 1,
312 profile: "battery-saver",
313 overrides: {},
314 });
315 expect(next.backgroundActivityProfile).toBe("battery-saver");
316 expect(Duration.toMillis(next.automaticGitFetchInterval)).toBe(0);
317 expect(Duration.toMillis(next.providerHealthRefreshInterval)).toBe(
318 Duration.toMillis(Duration.minutes(15)),
319 );
320 });
321
322 it("turns legacy interval patches into custom background activity overrides", () => {
323 const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
324 automaticGitFetchInterval: Duration.seconds(15),
325 });
326
327 expect(next.backgroundActivity).toEqual({
328 schemaVersion: 1,
329 profile: "custom",
330 baseProfile: "balanced",
331 overrides: {
332 automaticGitFetchInterval: Duration.seconds(15),
333 },
334 });
335 expect(resolveServerBackgroundActivitySettings(next).profile).toBe("balanced");
336 expect(
337 Duration.toMillis(resolveServerBackgroundActivitySettings(next).automaticGitFetchInterval),
338 ).toBe(15_000);
339 });
340
341 it("preserves legacy background activity settings when applying an unrelated patch", () => {
342 const current = {
343 ...DEFAULT_SERVER_SETTINGS,
344 backgroundActivityProfile: "performance" as const,
345 automaticGitFetchInterval: Duration.seconds(7),
346 providerHealthRefreshInterval: Duration.minutes(4),
347 };
348
349 const next = applyServerSettingsPatch(current, {
350 sourceControlWriterModelSelection: createModelSelection(
351 ProviderInstanceId.make("codex"),
352 "gpt-5.4-mini",
353 ),
354 });
355
356 expect(next.backgroundActivity).toEqual({
357 schemaVersion: 1,
358 profile: "custom",
359 baseProfile: "performance",
360 overrides: {
361 automaticGitFetchInterval: Duration.seconds(7),
362 providerHealthRefreshInterval: Duration.minutes(4),
363 },
364 });
365 expect(next.backgroundActivityProfile).toBe("performance");
366 expect(Duration.toMillis(next.automaticGitFetchInterval)).toBe(7_000);
367 expect(Duration.toMillis(next.providerHealthRefreshInterval)).toBe(240_000);
368 });
369
370 it("does not reactivate dormant overrides from a concrete profile", () => {
371 const current = {
372 ...DEFAULT_SERVER_SETTINGS,
373 backgroundActivity: {
374 schemaVersion: 1 as const,
375 profile: "battery-saver" as const,
376 overrides: {
377 providerHealthRefreshInterval: Duration.seconds(5),
378 },
379 },
380 };
381
382 const next = applyServerSettingsPatch(current, {
383 automaticGitFetchInterval: Duration.seconds(15),
384 });
385
386 expect(next.backgroundActivity).toEqual({
387 schemaVersion: 1,
388 profile: "custom",
389 baseProfile: "battery-saver",
390 overrides: {
391 automaticGitFetchInterval: Duration.seconds(15),
392 },
393 });
394 });
395
396 it("prefers structured background activity settings over legacy aliases", () => {
397 const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
398 backgroundActivity: {
399 schemaVersion: 1,
400 profile: "battery-saver",
401 overrides: {},
402 },
403 automaticGitFetchInterval: Duration.seconds(5),
404 backgroundActivityProfile: "performance",
405 });
406
407 expect(next.backgroundActivity).toEqual({
408 schemaVersion: 1,
409 profile: "battery-saver",
410 overrides: {},
411 });
412 expect(next.backgroundActivityProfile).toBe("battery-saver");
413 expect(Duration.toMillis(next.automaticGitFetchInterval)).toBe(0);
414 });
415
416 it("reconciles custom background activity back to a preset when overrides match the preset", () => {
417 const custom = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
418 automaticGitFetchInterval: Duration.seconds(15),
419 });
420 const next = applyServerSettingsPatch(custom, {
421 automaticGitFetchInterval: Duration.seconds(30),
422 });
423
424 expect(next.backgroundActivity).toEqual({
425 schemaVersion: 1,
426 profile: "balanced",
427 overrides: {},
428 });
429 expect(next.backgroundActivityProfile).toBe("balanced");
430 expect(Duration.toMillis(next.automaticGitFetchInterval)).toBe(30_000);
431 });
432
433 it("drops custom overrides that duplicate the base profile", () => {
434 const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
435 backgroundActivity: {
436 schemaVersion: 1,
437 profile: "custom",
438 baseProfile: "balanced",
439 overrides: {
440 automaticGitFetchInterval: Duration.seconds(30),
441 },
442 },
443 });
444
445 expect(next.backgroundActivity).toEqual({
446 schemaVersion: 1,
447 profile: "balanced",
448 overrides: {},
449 });
450 });
451
452 it("replaces the complete background override record", () => {
453 const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
454 backgroundActivity: {
455 schemaVersion: 1,
456 profile: "custom",
457 baseProfile: "balanced",
458 overrides: {
459 automaticGitFetchInterval: Duration.seconds(15),
460 providerHealthRefreshInterval: Duration.minutes(3),
461 },
462 },
463 });
464
465 const next = applyServerSettingsPatch(current, {
466 backgroundActivity: {
467 overrides: {
468 automaticGitFetchInterval: Duration.seconds(10),
469 },
470 },
471 });
472
473 expect(next.backgroundActivity).toEqual({
474 schemaVersion: 1,
475 profile: "custom",
476 baseProfile: "balanced",
477 overrides: {
478 automaticGitFetchInterval: Duration.seconds(10),
479 },
480 });
481 });
482
483 it("keeps interval overrides supplied with a profile patch", () => {
484 const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
485 backgroundActivityProfile: "performance",
486 automaticGitFetchInterval: Duration.seconds(0),
487 providerHealthRefreshInterval: Duration.minutes(4),
488 });
489
490 expect(next.backgroundActivity).toEqual({
491 schemaVersion: 1,
492 profile: "custom",
493 baseProfile: "performance",
494 overrides: {
495 automaticGitFetchInterval: Duration.seconds(0),
496 providerHealthRefreshInterval: Duration.minutes(4),
497 },
498 });
499 });
500
501 it("ignores overrides attached to a concrete background profile", () => {
502 const resolved = resolveServerBackgroundActivitySettings({
503 ...DEFAULT_SERVER_SETTINGS,
504 backgroundActivity: {
505 schemaVersion: 1,
506 profile: "balanced",
507 overrides: {
508 pauseWhenOnBattery: true,
509 },
510 },
511 });
512
513 expect(resolved.pauseWhenOnBattery).toBe(false);
514 });
298515});
packages/shared/src/serverSettings.ts+76-2
......@@ -12,6 +12,12 @@ import * as Schema from "effect/Schema";
1212import { deepMerge } from "./Struct.ts";
1313import { fromLenientJson } from "./schemaJson.ts";
1414import { createModelSelection } from "./model.ts";
15import {
16 getBackgroundActivityBaseProfile,
17 normalizeBackgroundActivitySettings,
18 normalizeServerBackgroundActivitySettings,
19 resolveBackgroundActivitySettings,
20} from "./backgroundActivitySettings.ts";
1521
1622const ServerSettingsJson = fromLenientJson(ServerSettings);
1723const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson);
......@@ -120,10 +126,64 @@ export function applyServerSettingsPatch(
120126 patch: ServerSettingsPatch,
121127): ServerSettings {
122128 const selectionPatch = patch.textGenerationModelSelection;
123 const { automaticGitFetchInterval, ...patchForMerge } = patch;
129 const {
130 automaticGitFetchInterval,
131 providerHealthRefreshInterval,
132 backgroundActivityProfile,
133 backgroundActivity,
134 ...patchForMerge
135 } = patch;
136 const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current);
137 const backgroundActivityPatch =
138 backgroundActivityProfile !== undefined
139 ? {
140 schemaVersion: 1 as const,
141 profile:
142 automaticGitFetchInterval !== undefined || providerHealthRefreshInterval !== undefined
143 ? ("custom" as const)
144 : backgroundActivityProfile,
145 ...(automaticGitFetchInterval !== undefined || providerHealthRefreshInterval !== undefined
146 ? { baseProfile: backgroundActivityProfile }
147 : {}),
148 overrides: {
149 ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}),
150 ...(providerHealthRefreshInterval !== undefined
151 ? { providerHealthRefreshInterval }
152 : {}),
153 },
154 }
155 : automaticGitFetchInterval !== undefined || providerHealthRefreshInterval !== undefined
156 ? {
157 schemaVersion: 1 as const,
158 profile: "custom" as const,
159 baseProfile: getBackgroundActivityBaseProfile(currentBackgroundActivity),
160 overrides: {
161 ...(currentBackgroundActivity.profile === "custom"
162 ? currentBackgroundActivity.overrides
163 : {}),
164 ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}),
165 ...(providerHealthRefreshInterval !== undefined
166 ? { providerHealthRefreshInterval }
167 : {}),
168 },
169 }
170 : undefined;
124171 const next = deepMerge(current, patchForMerge);
125 const nextWithReplacements = {
172 const nextWithReplacementsBase = {
126173 ...next,
174 ...(backgroundActivity !== undefined
175 ? {
176 backgroundActivity: {
177 ...deepMerge(currentBackgroundActivity, backgroundActivity),
178 ...(backgroundActivity.overrides !== undefined
179 ? { overrides: backgroundActivity.overrides }
180 : {}),
181 },
182 }
183 : { backgroundActivity: currentBackgroundActivity }),
184 ...(backgroundActivity === undefined && backgroundActivityPatch !== undefined
185 ? { backgroundActivity: backgroundActivityPatch }
186 : {}),
127187 ...(patch.providerInstances !== undefined
128188 ? { providerInstances: patch.providerInstances }
129189 : {}),
......@@ -131,6 +191,20 @@ export function applyServerSettingsPatch(
131191 ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection }
132192 : {}),
133193 ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}),
194 ...(providerHealthRefreshInterval !== undefined ? { providerHealthRefreshInterval } : {}),
195 };
196 const normalizedBackgroundActivity = normalizeBackgroundActivitySettings(
197 nextWithReplacementsBase.backgroundActivity,
198 );
199 const resolvedBackgroundActivity = resolveBackgroundActivitySettings(
200 normalizedBackgroundActivity,
201 );
202 const nextWithReplacements = {
203 ...nextWithReplacementsBase,
204 backgroundActivity: normalizedBackgroundActivity,
205 automaticGitFetchInterval: resolvedBackgroundActivity.automaticGitFetchInterval,
206 providerHealthRefreshInterval: resolvedBackgroundActivity.providerHealthRefreshInterval,
207 backgroundActivityProfile: resolvedBackgroundActivity.profile,
134208 };
135209 if (!selectionPatch) {
136210 return nextWithReplacements;
scripts/build-desktop-artifact.test.ts+50
......@@ -15,9 +15,11 @@ import {
1515 createBuildConfig,
1616 DESKTOP_ELECTRON_LANGUAGES,
1717 DESKTOP_FILE_EXCLUSIONS,
18 DESKTOP_EXTRA_RESOURCES,
1819 InvalidMacPasskeyRpDomainError,
1920 InvalidMacPasskeyPublishableKeyError,
2021 InvalidMockUpdateServerPortError,
22 UnsupportedDesktopBuildArchitectureError,
2123 isMacPasskeySigningConfigurationError,
2224 LinuxIconResizeError,
2325 MacPasskeySigningConfigurationResolutionError,
......@@ -32,6 +34,8 @@ import {
3234 resolveDesktopProductName,
3335 resolveDesktopUpdateChannel,
3436 resolveDesktopWebAssetBrand,
37 resolveResourceMonitorRustTargets,
38 resourceMonitorExecutableName,
3539 resolveGitHubPublishConfig,
3640 resolveMockUpdateServerPort,
3741 resolveMockUpdateServerUrl,
......@@ -543,6 +547,26 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
543547 }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),
544548 );
545549
550 it("stages the resource monitor as an external executable resource", () => {
551 assert.deepStrictEqual(DESKTOP_EXTRA_RESOURCES, [
552 {
553 from: "apps/desktop/prod-resources/resource-monitor",
554 to: "resource-monitor",
555 },
556 ]);
557 assert.deepStrictEqual(resolveResourceMonitorRustTargets("mac", "universal"), [
558 "aarch64-apple-darwin",
559 "x86_64-apple-darwin",
560 ]);
561 assert.deepStrictEqual(resolveResourceMonitorRustTargets("linux", "x64"), [
562 "x86_64-unknown-linux-gnu",
563 ]);
564 assert.deepStrictEqual(resolveResourceMonitorRustTargets("win", "arm64"), [
565 "aarch64-pc-windows-msvc",
566 ]);
567 assert.equal(resourceMonitorExecutableName("mac"), "t3-resource-monitor");
568 assert.equal(resourceMonitorExecutableName("win"), "t3-resource-monitor.exe");
569 });
546570 it("promotes target fff binaries to direct staged dependencies", () => {
547571 assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), {
548572 "@ff-labs/fff-bin-darwin-arm64": "0.9.4",
......@@ -673,6 +697,32 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
673697 }),
674698 );
675699
700 it.effect("rejects universal builds on Linux and Windows before staging binaries", () =>
701 Effect.gen(function* () {
702 for (const platform of ["linux", "win"] as const) {
703 const error = yield* Effect.flip(
704 resolveBuildOptions({
705 platform: Option.some(platform),
706 target: Option.none(),
707 arch: Option.some("universal"),
708 buildVersion: Option.none(),
709 outputDir: Option.none(),
710 skipBuild: Option.none(),
711 keepStage: Option.none(),
712 signed: Option.none(),
713 verbose: Option.none(),
714 mockUpdates: Option.none(),
715 mockUpdateServerPort: Option.none(),
716 wslPrebuild: Option.none(),
717 }),
718 );
719
720 assert.instanceOf(error, UnsupportedDesktopBuildArchitectureError);
721 assert.deepStrictEqual(error.supportedArchitectures, ["x64", "arm64"]);
722 }
723 }),
724 );
725
676726 it.effect("preserves explicit false boolean flags over true env defaults", () =>
677727 Effect.gen(function* () {
678728 const resolved = yield* resolveBuildOptions({
scripts/build-desktop-artifact.ts+144
......@@ -94,6 +94,26 @@ interface PlatformConfig {
9494 readonly archChoices: ReadonlyArray<typeof BuildArch.Type>;
9595}
9696
97export function resolveResourceMonitorRustTargets(
98 platform: typeof BuildPlatform.Type,
99 arch: typeof BuildArch.Type,
100): ReadonlyArray<string> {
101 if (platform === "mac") {
102 if (arch === "universal") {
103 return ["aarch64-apple-darwin", "x86_64-apple-darwin"];
104 }
105 return [arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"];
106 }
107 if (platform === "linux") {
108 return [arch === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"];
109 }
110 return [arch === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc"];
111}
112
113export function resourceMonitorExecutableName(platform: typeof BuildPlatform.Type): string {
114 return platform === "win" ? "t3-resource-monitor.exe" : "t3-resource-monitor";
115}
116
97117const PLATFORM_CONFIG: Record<typeof BuildPlatform.Type, PlatformConfig> = {
98118 mac: {
99119 cliFlag: "--mac",
......@@ -189,6 +209,19 @@ export class UnsupportedHostBuildPlatformError extends Schema.TaggedErrorClass<U
189209 }
190210}
191211
212export class UnsupportedDesktopBuildArchitectureError extends Schema.TaggedErrorClass<UnsupportedDesktopBuildArchitectureError>()(
213 "UnsupportedDesktopBuildArchitectureError",
214 {
215 platform: BuildPlatform,
216 arch: BuildArch,
217 supportedArchitectures: Schema.Array(BuildArch),
218 },
219) {
220 override get message(): string {
221 return `Unsupported architecture '${this.arch}' for ${this.platform}.`;
222 }
223}
224
192225const InvalidMockUpdateServerPortReason = Schema.Literals([
193226 "not-numeric",
194227 "not-integer",
......@@ -236,6 +269,20 @@ export class BuildCommandFailedError extends Schema.TaggedErrorClass<BuildComman
236269 }
237270}
238271
272export class ResourceMonitorBuildOutputMissingError extends Schema.TaggedErrorClass<ResourceMonitorBuildOutputMissingError>()(
273 "ResourceMonitorBuildOutputMissingError",
274 {
275 binaryPath: Schema.String,
276 rustTarget: Schema.String,
277 platform: BuildPlatform,
278 arch: BuildArch,
279 },
280) {
281 override get message(): string {
282 return `Resource monitor build for ${this.rustTarget} did not produce ${this.binaryPath}.`;
283 }
284}
285
239286const desktopIconPlatformNames = {
240287 mac: "macOS",
241288 linux: "Linux",
......@@ -593,6 +640,12 @@ export const DESKTOP_FILE_EXCLUSIONS = [
593640// The Windows primary backend reads the same files through the asar redirect,
594641// so nothing is duplicated.
595642export const WINDOWS_ASAR_UNPACK = ["apps/server/dist/**", "**/node_modules/**"] as const;
643export const DESKTOP_EXTRA_RESOURCES = [
644 {
645 from: "apps/desktop/prod-resources/resource-monitor",
646 to: "resource-monitor",
647 },
648] as const;
596649
597650export interface MacPasskeySigningConfiguration {
598651 readonly appId: string;
......@@ -1044,6 +1097,14 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (
10441097 const target = mergeOptions(input.target, env.target, PLATFORM_CONFIG[platform].defaultTarget);
10451098 const defaultArch = yield* getDefaultArch(platform);
10461099 const arch = mergeOptions(input.arch, env.arch, defaultArch);
1100 const supportedArchitectures = PLATFORM_CONFIG[platform].archChoices;
1101 if (!supportedArchitectures.includes(arch)) {
1102 return yield* new UnsupportedDesktopBuildArchitectureError({
1103 platform,
1104 arch,
1105 supportedArchitectures: [...supportedArchitectures],
1106 });
1107 }
10471108 const version = mergeOptions(input.buildVersion, env.version, undefined);
10481109 const releaseDir = resolveBooleanFlag(input.mockUpdates, env.mockUpdates)
10491110 ? "release-mock"
......@@ -1117,6 +1178,81 @@ const runCommand = Effect.fn("runCommand")(function* (
11171178 }
11181179});
11191180
1181const stageResourceMonitor = Effect.fn("stageResourceMonitor")(function* (input: {
1182 readonly repoRoot: string;
1183 readonly stageResourcesDir: string;
1184 readonly platform: typeof BuildPlatform.Type;
1185 readonly arch: typeof BuildArch.Type;
1186 readonly verbose: boolean;
1187}) {
1188 const fs = yield* FileSystem.FileSystem;
1189 const path = yield* Path.Path;
1190 const manifestPath = path.join(input.repoRoot, "native/resource-monitor/Cargo.toml");
1191 const executableName = resourceMonitorExecutableName(input.platform);
1192 const rustTargets = resolveResourceMonitorRustTargets(input.platform, input.arch);
1193 const builtBinaries: string[] = [];
1194
1195 for (const rustTarget of rustTargets) {
1196 const spawnCommand = yield* resolveSpawnCommand("cargo", [
1197 "build",
1198 "--locked",
1199 "--release",
1200 "--manifest-path",
1201 manifestPath,
1202 "--target",
1203 rustTarget,
1204 ]);
1205 yield* runCommand(
1206 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
1207 cwd: input.repoRoot,
1208 shell: spawnCommand.shell,
1209 }),
1210 {
1211 label: `cargo build resource monitor (${rustTarget})`,
1212 verbose: input.verbose,
1213 },
1214 );
1215
1216 const binaryPath = path.join(
1217 input.repoRoot,
1218 "native/resource-monitor/target",
1219 rustTarget,
1220 "release",
1221 executableName,
1222 );
1223 if (!(yield* fs.exists(binaryPath))) {
1224 return yield* new ResourceMonitorBuildOutputMissingError({
1225 binaryPath,
1226 rustTarget,
1227 platform: input.platform,
1228 arch: input.arch,
1229 });
1230 }
1231 builtBinaries.push(binaryPath);
1232 }
1233
1234 const destinationDirectory = path.join(input.stageResourcesDir, "resource-monitor");
1235 const destinationPath = path.join(destinationDirectory, executableName);
1236 yield* fs.remove(destinationDirectory, { recursive: true, force: true }).pipe(Effect.ignore);
1237 yield* fs.makeDirectory(destinationDirectory, { recursive: true });
1238
1239 if (builtBinaries.length === 1) {
1240 yield* fs.copyFile(builtBinaries[0]!, destinationPath);
1241 } else {
1242 yield* runCommand(
1243 ChildProcess.make("lipo", ["-create", ...builtBinaries, "-output", destinationPath]),
1244 {
1245 label: "lipo resource monitor universal binary",
1246 verbose: input.verbose,
1247 },
1248 );
1249 }
1250
1251 if (input.platform !== "win") {
1252 yield* fs.chmod(destinationPath, 0o755);
1253 }
1254});
1255
11201256function generateMacIconSet(
11211257 sourcePng: string,
11221258 targetIcns: string,
......@@ -1412,6 +1548,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
14121548 // WINDOWS_ASAR_UNPACK); macOS and Linux stay packed — smart unpack
14131549 // extracts native libraries, which fff-node finds in app.asar.unpacked.
14141550 ...(platform === "win" ? { asarUnpack: [...WINDOWS_ASAR_UNPACK] } : {}),
1551 extraResources: DESKTOP_EXTRA_RESOURCES,
14151552 };
14161553 const updateChannel = resolveDesktopUpdateChannel(version);
14171554 const publishConfig = yield* resolveGitHubPublishConfig(updateChannel);
......@@ -1691,6 +1828,13 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
16911828 yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"));
16921829 yield* fs.copy(distDirs.desktopResources, stageResourcesDir);
16931830 yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"));
1831 yield* stageResourceMonitor({
1832 repoRoot,
1833 stageResourcesDir,
1834 platform: options.platform,
1835 arch: options.arch,
1836 verbose: options.verbose,
1837 });
16941838
16951839 yield* assertPlatformBuildResources(
16961840 options.platform,