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:...@@ -22,6 +22,11 @@ jobs:
22 cache: true22 cache: true
23 run-install: true23 run-install: true
2424
25 - name: Setup Rust
26 uses: dtolnay/rust-toolchain@stable
27 with:
28 components: rustfmt
29
25 - name: Ensure Electron runtime is installed30 - name: Ensure Electron runtime is installed
26 run: vp run --filter @t3tools/desktop ensure:electron31 run: vp run --filter @t3tools/desktop ensure:electron
2732
...@@ -31,6 +36,9 @@ jobs:...@@ -31,6 +36,9 @@ jobs:
31 - name: Typecheck36 - name: Typecheck
32 run: vpr typecheck37 run: vpr typecheck
3338
39 - name: Check resource monitor formatting
40 run: cargo fmt --manifest-path native/resource-monitor/Cargo.toml -- --check
41
34 - name: Build desktop pipeline42 - name: Build desktop pipeline
35 run: vp run build:desktop43 run: vp run build:desktop
3644
...@@ -55,12 +63,18 @@ jobs:...@@ -55,12 +63,18 @@ jobs:
55 cache: true63 cache: true
56 run-install: true64 run-install: true
5765
66 - name: Setup Rust
67 uses: dtolnay/rust-toolchain@stable
68
58 - name: Ensure Electron runtime is installed69 - name: Ensure Electron runtime is installed
59 run: vp run --filter @t3tools/desktop ensure:electron70 run: vp run --filter @t3tools/desktop ensure:electron
6071
61 - name: Test72 - name: Test
62 run: vp run test73 run: vp run test
6374
75 - name: Test resource monitor
76 run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml
77
64 mobile_native_static_analysis:78 mobile_native_static_analysis:
65 name: Mobile Native Static Analysis79 name: Mobile Native Static Analysis
66 runs-on: blacksmith-12vcpu-macos-2680 runs-on: blacksmith-12vcpu-macos-26
.github/workflows/release.yml+51
...@@ -326,21 +326,29 @@ jobs:...@@ -326,21 +326,29 @@ jobs:
326 platform: mac326 platform: mac
327 target: dmg327 target: dmg
328 arch: arm64328 arch: arm64
329 rust_target: aarch64-apple-darwin
330 resource_key: darwin-arm64
329 - label: macOS x64331 - label: macOS x64
330 runner: blacksmith-12vcpu-macos-26332 runner: blacksmith-12vcpu-macos-26
331 platform: mac333 platform: mac
332 target: dmg334 target: dmg
333 arch: x64335 arch: x64
336 rust_target: x86_64-apple-darwin
337 resource_key: darwin-x64
334 - label: Linux x64338 - label: Linux x64
335 runner: blacksmith-32vcpu-ubuntu-2404339 runner: blacksmith-32vcpu-ubuntu-2404
336 platform: linux340 platform: linux
337 target: AppImage341 target: AppImage
338 arch: x64342 arch: x64
343 rust_target: x86_64-unknown-linux-gnu
344 resource_key: linux-x64
339 - label: Windows x64345 - label: Windows x64
340 runner: blacksmith-32vcpu-windows-2025346 runner: blacksmith-32vcpu-windows-2025
341 platform: win347 platform: win
342 target: nsis348 target: nsis
343 arch: x64349 arch: x64
350 rust_target: x86_64-pc-windows-msvc
351 resource_key: win32-x64
344 # - label: Windows arm64352 # - label: Windows arm64
345 # runner: windows-11-arm353 # runner: windows-11-arm
346 # platform: win354 # platform: win
...@@ -360,6 +368,11 @@ jobs:...@@ -360,6 +368,11 @@ jobs:
360 cache: true368 cache: true
361 run-install: true369 run-install: true
362370
371 - name: Setup Rust
372 uses: dtolnay/rust-toolchain@stable
373 with:
374 targets: ${{ matrix.rust_target }}
375
363 - name: Download relay client tracing config376 - name: Download relay client tracing config
364 uses: actions/download-artifact@v8377 uses: actions/download-artifact@v8
365 with:378 with:
...@@ -598,6 +611,19 @@ jobs:...@@ -598,6 +611,19 @@ jobs:
598 # done611 # done
599 # fi612 # 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
601 - name: Upload build artifacts627 - name: Upload build artifacts
602 uses: actions/upload-artifact@v7628 uses: actions/upload-artifact@v7
603 with:629 with:
...@@ -605,6 +631,13 @@ jobs:...@@ -605,6 +631,13 @@ jobs:
605 path: release-publish/*631 path: release-publish/*
606 if-no-files-found: error632 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
608 publish_cli:641 publish_cli:
609 name: Publish CLI to npm642 name: Publish CLI to npm
610 needs: [preflight, relay_public_config, build]643 needs: [preflight, relay_public_config, build]
...@@ -659,6 +692,24 @@ jobs:...@@ -659,6 +692,24 @@ jobs:
659 - name: Build CLI package692 - name: Build CLI package
660 run: vp run --filter t3 build693 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
662 - name: Publish CLI package713 - name: Publish CLI package
663 run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose714 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/...@@ -30,6 +30,7 @@ dist-electron/
30.showcase/30.showcase/
31apps/mobile/.showcase/31apps/mobile/.showcase/
32artifacts/app-store/screenshots/32artifacts/app-store/screenshots/
33native/**/target/
33node_modules/34node_modules/
34.alchemy/35.alchemy/
35*.log36*.log
apps/desktop/src/app/DesktopAppIdentity.test.ts+1
...@@ -55,6 +55,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>...@@ -55,6 +55,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
55 }),55 }),
56 setAppUserModelId: () => Effect.void,56 setAppUserModelId: () => Effect.void,
57 requestSingleInstanceLock: Effect.succeed(true),57 requestSingleInstanceLock: Effect.succeed(true),
58 getAppMetrics: Effect.succeed([]),
58 isDefaultProtocolClient: () => Effect.succeed(false),59 isDefaultProtocolClient: () => Effect.succeed(false),
59 setAsDefaultProtocolClient: () => Effect.succeed(true),60 setAsDefaultProtocolClient: () => Effect.succeed(true),
60 setDesktopName: () => Effect.void,61 setDesktopName: () => Effect.void,
apps/desktop/src/app/DesktopLifecycle.test.ts+1
...@@ -30,6 +30,7 @@ describe("DesktopLifecycle", () => {...@@ -30,6 +30,7 @@ describe("DesktopLifecycle", () => {
30 setAboutPanelOptions: () => Effect.void,30 setAboutPanelOptions: () => Effect.void,
31 setAppUserModelId: () => Effect.void,31 setAppUserModelId: () => Effect.void,
32 requestSingleInstanceLock: Effect.succeed(true),32 requestSingleInstanceLock: Effect.succeed(true),
33 getAppMetrics: Effect.succeed([]),
33 isDefaultProtocolClient: () => Effect.succeed(false),34 isDefaultProtocolClient: () => Effect.succeed(false),
34 setAsDefaultProtocolClient: () => Effect.succeed(true),35 setAsDefaultProtocolClient: () => Effect.succeed(true),
35 setDesktopName: () => Effect.void,36 setDesktopName: () => Effect.void,
apps/desktop/src/app/DesktopObservability.test.ts+180-14
...@@ -49,20 +49,41 @@ const environmentInput = (baseDir: string) =>...@@ -49,20 +49,41 @@ const environmentInput = (baseDir: string) =>
49 runningUnderArm64Translation: false,49 runningUnderArm64Translation: false,
50 }) satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;50 }) satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;
5151
52const makeEnvironmentLayer = (baseDir: string) =>52const makeEnvironmentLayer = (baseDir: string, isDevelopment = true) =>
53 DesktopEnvironment.layer(environmentInput(baseDir)).pipe(53 DesktopEnvironment.layer(environmentInput(baseDir)).pipe(
54 Layer.provide(54 Layer.provide(
55 Layer.mergeAll(55 Layer.mergeAll(
56 NodeServices.layer,56 NodeServices.layer,
57 DesktopConfig.layerTest({57 DesktopConfig.layerTest({
58 T3CODE_HOME: baseDir,58 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,
60 }),60 }),
61 ),61 ),
62 ),62 ),
63 );63 );
6464
65describe("DesktopObservability", () => {65describe("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
66 it.effect("persists desktop Effect logs as span events in desktop.trace.ndjson", () =>87 it.effect("persists desktop Effect logs as span events in desktop.trace.ndjson", () =>
67 Effect.gen(function* () {88 Effect.gen(function* () {
68 const fileSystem = yield* FileSystem.FileSystem;89 const fileSystem = yield* FileSystem.FileSystem;
...@@ -112,26 +133,34 @@ describe("DesktopObservability", () => {...@@ -112,26 +133,34 @@ describe("DesktopObservability", () => {
112 ),133 ),
113 );134 );
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", () =>
116 Effect.gen(function* () {137 Effect.gen(function* () {
117 const fileSystem = yield* FileSystem.FileSystem;138 const fileSystem = yield* FileSystem.FileSystem;
118 const baseDir = yield* fileSystem.makeTempDirectoryScoped({139 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
119 prefix: "t3-desktop-backend-output-log-test-",140 prefix: "t3-desktop-backend-output-log-test-",
120 });141 });
121 const environmentLayer = makeEnvironmentLayer(baseDir);142 const environmentLayer = makeEnvironmentLayer(baseDir, false);
122 const logPath = yield* Effect.gen(function* () {143 const logPath = yield* Effect.gen(function* () {
123 const environment = yield* DesktopEnvironment.DesktopEnvironment;144 const environment = yield* DesktopEnvironment.DesktopEnvironment;
124 return environment.path.join(environment.logDir, "server-child.log");145 return environment.path.join(environment.logDir, "server-child.log");
125 }).pipe(Effect.provide(environmentLayer));146 }).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
127 yield* Effect.gen(function* () {152 yield* Effect.gen(function* () {
128 const factory = yield* DesktopObservability.DesktopBackendOutputLogFactory;153 const factory = yield* DesktopObservability.DesktopBackendOutputLogFactory;
129 const outputLog = yield* factory.forInstance("primary");154 const outputLog = yield* factory.forInstance("primary");
130 yield* outputLog.writeSessionBoundary({155 yield* outputLog.beginSession({
131 phase: "START",
132 details: "pid=123 port=3773 cwd=/repo",156 details: "pid=123 port=3773 cwd=/repo",
133 });157 });
134 yield* outputLog.writeOutputChunk("stdout", new TextEncoder().encode("hello server\n"));158 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;
135 }).pipe(164 }).pipe(
136 Effect.annotateLogs({ runId: "test-run" }),165 Effect.annotateLogs({ runId: "test-run" }),
137 Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))),166 Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))),
...@@ -139,16 +168,18 @@ describe("DesktopObservability", () => {...@@ -139,16 +168,18 @@ describe("DesktopObservability", () => {
139168
140 const log = yield* fileSystem.readFileString(logPath);169 const log = yield* fileSystem.readFileString(logPath);
141 const lines = log.trimEnd().split("\n");170 const lines = log.trimEnd().split("\n");
142 const boundary = yield* decodeDesktopBackendChildLogRecord(lines[0] ?? "");171 const start = yield* decodeDesktopBackendChildLogRecord(lines[0] ?? "");
143 const output = yield* decodeDesktopBackendChildLogRecord(lines[1] ?? "");172 const output = yield* decodeDesktopBackendChildLogRecord(lines[1] ?? "");
173 const end = yield* decodeDesktopBackendChildLogRecord(lines[2] ?? "");
144174
145 assert.equal(boundary.message, "backend child process session start");175 assert.equal(lines.length, 3);
146 assert.equal(boundary.level, "INFO");176 assert.equal(start.message, "backend child process failure output start");
147 assert.equal(boundary.annotations.component, "desktop-backend-child");177 assert.equal(start.level, "ERROR");
148 assert.equal(boundary.annotations.runId, "test-run");178 assert.equal(start.annotations.component, "desktop-backend-child");
149 assert.equal(boundary.annotations.instanceId, "primary");179 assert.equal(start.annotations.runId, "test-run");
150 assert.equal(boundary.annotations.phase, "START");180 assert.equal(start.annotations.instanceId, "primary");
151 assert.equal(boundary.annotations.details, "pid=123 port=3773 cwd=/repo");181 assert.equal(start.annotations.phase, "START");
182 assert.equal(start.annotations.details, "pid=123 port=3773 cwd=/repo");
152183
153 assert.equal(output.message, "backend child process output");184 assert.equal(output.message, "backend child process output");
154 assert.equal(output.level, "INFO");185 assert.equal(output.level, "INFO");
...@@ -157,6 +188,141 @@ describe("DesktopObservability", () => {...@@ -157,6 +188,141 @@ describe("DesktopObservability", () => {
157 assert.equal(output.annotations.instanceId, "primary");188 assert.equal(output.annotations.instanceId, "primary");
158 assert.equal(output.annotations.stream, "stdout");189 assert.equal(output.annotations.stream, "stdout");
159 assert.equal(output.annotations.text, "hello server\n");190 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);
160 }).pipe(326 }).pipe(
161 Effect.scoped,327 Effect.scoped,
162 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)),328 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";...@@ -24,7 +24,9 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts";
24const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024;24const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024;
25const DESKTOP_LOG_FILE_MAX_FILES = 10;25const DESKTOP_LOG_FILE_MAX_FILES = 10;
26const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child";26const 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
29export interface RotatingLogFileWriter {31export interface RotatingLogFileWriter {
30 readonly writeBytes: (chunk: Uint8Array) => Effect.Effect<void>;32 readonly writeBytes: (chunk: Uint8Array) => Effect.Effect<void>;
...@@ -32,14 +34,14 @@ export interface RotatingLogFileWriter {...@@ -32,14 +34,14 @@ export interface RotatingLogFileWriter {
32}34}
3335
34export interface DesktopBackendOutputLogShape {36export interface DesktopBackendOutputLogShape {
35 readonly writeSessionBoundary: (input: {37 readonly beginSession: (input: { readonly details: string }) => Effect.Effect<void>;
36 readonly phase: "START" | "END";
37 readonly details: string;
38 }) => Effect.Effect<void>;
39 readonly writeOutputChunk: (38 readonly writeOutputChunk: (
40 streamName: "stdout" | "stderr",39 streamName: "stdout" | "stderr",
41 chunk: Uint8Array,40 chunk: Uint8Array,
42 ) => Effect.Effect<void>;41 ) => 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>;
43}45}
4446
45// Factory for per-instance backend output logs. `forInstance(id)` returns47// Factory for per-instance backend output logs. `forInstance(id)` returns
...@@ -127,10 +129,80 @@ const encodeDesktopBackendChildLogRecord = Schema.encodeEffect(...@@ -127,10 +129,80 @@ const encodeDesktopBackendChildLogRecord = Schema.encodeEffect(
127);129);
128130
129const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = {131const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = {
130 writeSessionBoundary: () => Effect.void,132 beginSession: () => Effect.void,
131 writeOutputChunk: () => Effect.void,133 writeOutputChunk: () => Effect.void,
134 persistFailureSnapshot: () => Effect.void,
135 persistFailure: () => Effect.void,
136 discardSession: Effect.void,
132};137};
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
134const currentDesktopRunId = Effect.gen(function* () {206const currentDesktopRunId = Effect.gen(function* () {
135 const annotations = yield* References.CurrentLogAnnotations;207 const annotations = yield* References.CurrentLogAnnotations;
136 const runId = annotations.runId;208 const runId = annotations.runId;
...@@ -345,47 +417,93 @@ const makeBackendOutputLogShape = (...@@ -345,47 +417,93 @@ const makeBackendOutputLogShape = (
345 environment: DesktopEnvironment.DesktopEnvironment["Service"],417 environment: DesktopEnvironment.DesktopEnvironment["Service"],
346 id: string,418 id: string,
347 sink: Option.Option<RotatingLogFileWriter>,419 sink: Option.Option<RotatingLogFileWriter>,
348): DesktopBackendOutputLogShape =>420): Effect.Effect<DesktopBackendOutputLogShape> =>
349 Option.match(sink, {421 Option.match(sink, {
350 onNone: () => DesktopBackendOutputLogNoop,422 onNone: () => Effect.succeed(DesktopBackendOutputLogNoop),
351 onSome: (logFile) =>423 onSome: (logFile) =>
352 ({424 Effect.gen(function* () {
353 writeSessionBoundary: Effect.fn("desktop.observability.backendOutput.writeSessionBoundary")(425 const sessionRef = yield* Ref.make(Option.none<BackendOutputSession>());
354 function* ({ phase, details }) {426 const writeFailure = Effect.fn("desktop.observability.backendOutput.writeFailure")(
355 const runId = yield* currentDesktopRunId;427 function* (session: BackendOutputSession, details: string) {
356 yield* writeBackendChildLogRecord(logFile, {428 yield* writeBackendChildLogRecord(logFile, {
357 message: `backend child process session ${phase.toLowerCase()}`,429 message: "backend child process failure output start",
358 level: "INFO",430 level: "ERROR",
359 annotations: {431 annotations: {
360 component: "desktop-backend-child",432 component: "desktop-backend-child",
361 runId,433 runId: session.runId,
362 instanceId: id,434 instanceId: id,
363 phase,435 phase: "START",
364 details: sanitizeLogValue(details),436 details: session.startDetails,
365 },437 },
366 });438 });
367 },439 for (const output of session.chunks) {
368 ),440 yield* writeBackendChildLogRecord(logFile, {
369 writeOutputChunk: Effect.fn("desktop.observability.backendOutput.writeOutputChunk")(441 message: "backend child process output",
370 function* (streamName, chunk) {442 level: output.streamName === "stderr" ? "ERROR" : "INFO",
371 if (environment.isDevelopment) {443 annotations: {
372 yield* writeDevelopmentConsoleOutput(streamName, chunk);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 });
373 }451 }
374 const runId = yield* currentDesktopRunId;
375 yield* writeBackendChildLogRecord(logFile, {452 yield* writeBackendChildLogRecord(logFile, {
376 message: "backend child process output",453 message: "backend child process failure output end",
377 level: streamName === "stderr" ? "ERROR" : "INFO",454 level: "ERROR",
378 annotations: {455 annotations: {
379 component: "desktop-backend-child",456 component: "desktop-backend-child",
380 runId,457 runId: session.runId,
381 instanceId: id,458 instanceId: id,
382 stream: streamName,459 phase: "END",
383 text: textDecoder.decode(chunk),460 details: sanitizeLogValue(details),
384 },461 },
385 });462 });
386 },463 },
387 ),464 );
388 }) satisfies DesktopBackendOutputLogShape,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 }),
389 });507 });
390508
391const backendOutputLogFactoryLayer = Layer.effect(509const backendOutputLogFactoryLayer = Layer.effect(
...@@ -412,10 +530,9 @@ const backendOutputLogFactoryLayer = Layer.effect(...@@ -412,10 +530,9 @@ const backendOutputLogFactoryLayer = Layer.effect(
412 const cacheKey = backendLogFilePathForInstance(environment, id);530 const cacheKey = backendLogFilePathForInstance(environment, id);
413 const cached = cache.get(cacheKey);531 const cached = cache.get(cacheKey);
414 if (cached !== undefined) {532 if (cached !== undefined) {
415 return Effect.succeed([533 return makeBackendOutputLogShape(environment, id, cached).pipe(
416 makeBackendOutputLogShape(environment, id, cached),534 Effect.map((outputLog) => [outputLog, cache] as const),
417 cache,535 );
418 ] as const);
419 }536 }
420 return makeBackendOutputSinkForInstance(environment, id).pipe(537 return makeBackendOutputSinkForInstance(environment, id).pipe(
421 Effect.provideService(FileSystem.FileSystem, fileSystem),538 Effect.provideService(FileSystem.FileSystem, fileSystem),
...@@ -424,11 +541,19 @@ const backendOutputLogFactoryLayer = Layer.effect(...@@ -424,11 +541,19 @@ const backendOutputLogFactoryLayer = Layer.effect(
424 Effect.map((sink) => {541 Effect.map((sink) => {
425 const next = new Map(cache);542 const next = new Map(cache);
426 next.set(cacheKey, sink);543 next.set(cacheKey, sink);
427 return [544 return { sink, next };
428 makeBackendOutputLogShape(environment, id, sink),
429 next as ReadonlyMap<string, Option.Option<RotatingLogFileWriter>>,
430 ] as const;
431 }),545 }),
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 ),
432 );557 );
433 });558 });
434559
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+95-1
...@@ -52,6 +52,7 @@ function makeEnvironmentLayer(...@@ -52,6 +52,7 @@ function makeEnvironmentLayer(
52 baseDir: string,52 baseDir: string,
53 options?: {53 options?: {
54 readonly appPath?: string;54 readonly appPath?: string;
55 readonly dirname?: string;
55 readonly isPackaged?: boolean;56 readonly isPackaged?: boolean;
56 readonly devServerUrl?: string;57 readonly devServerUrl?: string;
57 readonly platform?: NodeJS.Platform;58 readonly platform?: NodeJS.Platform;
...@@ -59,7 +60,7 @@ function makeEnvironmentLayer(...@@ -59,7 +60,7 @@ function makeEnvironmentLayer(
59 },60 },
60) {61) {
61 return DesktopEnvironment.layer({62 return DesktopEnvironment.layer({
62 dirname: "/repo/apps/desktop/src",63 dirname: options?.dirname ?? "/repo/apps/desktop/src",
63 homeDirectory: baseDir,64 homeDirectory: baseDir,
64 platform: options?.platform ?? "darwin",65 platform: options?.platform ?? "darwin",
65 processArch: "x64",66 processArch: "x64",
...@@ -464,6 +465,8 @@ describe("DesktopBackendConfiguration", () => {...@@ -464,6 +465,8 @@ describe("DesktopBackendConfiguration", () => {
464 // both wslhost-forwarded localhost and the distro's eth0 IP.465 // both wslhost-forwarded localhost and the distro's eth0 IP.
465 assert.equal(config.bootstrap.host, "0.0.0.0");466 assert.equal(config.bootstrap.host, "0.0.0.0");
466 assert.equal(config.bootstrap.tailscaleServeEnabled, false);467 assert.equal(config.bootstrap.tailscaleServeEnabled, false);
468 assert.notProperty(config.bootstrap, "desktopTelemetryFd");
469 assert.notProperty(config.bootstrap, "resourceMonitorPath");
467 // httpBaseUrl uses the resolved distro IP from the test stub,470 // httpBaseUrl uses the resolved distro IP from the test stub,
468 // not localhost — the renderer reaches the backend directly to471 // not localhost — the renderer reaches the backend directly to
469 // avoid relying on wslhost forwarding.472 // avoid relying on wslhost forwarding.
...@@ -713,6 +716,97 @@ describe("DesktopBackendConfiguration", () => {...@@ -713,6 +716,97 @@ describe("DesktopBackendConfiguration", () => {
713 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),716 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
714 );717 );
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
716 it.effect("resolvePrimaryLabel reports the local environment on non-Windows platforms", () =>810 it.effect("resolvePrimaryLabel reports the local environment on non-Windows platforms", () =>
717 withHarness(811 withHarness(
718 Effect.gen(function* () {812 Effect.gen(function* () {
apps/desktop/src/backend/DesktopBackendConfiguration.ts+56-2
...@@ -141,6 +141,44 @@ const logBackendObservabilitySettingsReadFailure = (...@@ -141,6 +141,44 @@ const logBackendObservabilitySettingsReadFailure = (
141 );141 );
142};142};
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
144const readPersistedBackendObservabilitySettings = Effect.gen(function* () {182const readPersistedBackendObservabilitySettings = Effect.gen(function* () {
145 const fileSystem = yield* FileSystem.FileSystem;183 const fileSystem = yield* FileSystem.FileSystem;
146 const environment = yield* DesktopEnvironment.DesktopEnvironment;184 const environment = yield* DesktopEnvironment.DesktopEnvironment;
...@@ -326,7 +364,9 @@ const buildObservabilityFragment = (observabilitySettings: BackendObservabilityS...@@ -326,7 +364,9 @@ const buildObservabilityFragment = (observabilitySettings: BackendObservabilityS
326364
327const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolvePrimary")(365const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolvePrimary")(
328 function* (366 function* (
329 input: SharedBootstrapInput,367 input: SharedBootstrapInput & {
368 readonly resourceMonitorPath: Option.Option<string>;
369 },
330 ): Effect.fn.Return<370 ): Effect.fn.Return<
331 DesktopBackendManager.DesktopBackendStartConfig,371 DesktopBackendManager.DesktopBackendStartConfig,
332 never,372 never,
...@@ -345,6 +385,12 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv...@@ -345,6 +385,12 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
345 desktopBootstrapToken: input.bootstrapToken,385 desktopBootstrapToken: input.bootstrapToken,
346 tailscaleServeEnabled: backendExposure.tailscaleServeEnabled,386 tailscaleServeEnabled: backendExposure.tailscaleServeEnabled,
347 tailscaleServePort: backendExposure.tailscaleServePort,387 tailscaleServePort: backendExposure.tailscaleServePort,
388 desktopTelemetryFd: 4,
389 desktopTelemetryControlFd: 5,
390 ...Option.match(input.resourceMonitorPath, {
391 onNone: () => ({}),
392 onSome: (resourceMonitorPath) => ({ resourceMonitorPath }),
393 }),
348 ...buildObservabilityFragment(input.observabilitySettings),394 ...buildObservabilityFragment(input.observabilitySettings),
349 };395 };
350396
...@@ -411,6 +457,10 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl...@@ -411,6 +457,10 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
411 // inert.457 // inert.
412 tailscaleServeEnabled: false,458 tailscaleServeEnabled: false,
413 tailscaleServePort: 443,459 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.
414 ...buildObservabilityFragment(input.observabilitySettings),464 ...buildObservabilityFragment(input.observabilitySettings),
415 };465 };
416466
...@@ -621,7 +671,11 @@ export const make = Effect.gen(function* () {...@@ -621,7 +671,11 @@ export const make = Effect.gen(function* () {
621671
622 const buildWindowsPrimaryConfig = Effect.gen(function* () {672 const buildWindowsPrimaryConfig = Effect.gen(function* () {
623 const shared = yield* sharedInputs;673 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(
625 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),679 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
626 Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure),680 Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure),
627 );681 );
apps/desktop/src/backend/DesktopBackendManager.test.ts+700-16
...@@ -1,14 +1,17 @@...@@ -1,14 +1,17 @@
1import {1import {
2 DesktopBackendBootstrap,2 DesktopBackendBootstrap,
3 type DesktopBackendBootstrap as DesktopBackendBootstrapValue,3 type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
4 DesktopTelemetryControlMessage,
4} from "@t3tools/contracts";5} from "@t3tools/contracts";
5import { assert, describe, it } from "@effect/vitest";6import { assert, describe, it } from "@effect/vitest";
6import * as Deferred from "effect/Deferred";7import * as Deferred from "effect/Deferred";
7import * as Duration from "effect/Duration";8import * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";10import * as FileSystem from "effect/FileSystem";
11import * as Fiber from "effect/Fiber";
10import * as Layer from "effect/Layer";12import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";13import * as Option from "effect/Option";
14import * as PlatformError from "effect/PlatformError";
12import * as Queue from "effect/Queue";15import * as Queue from "effect/Queue";
13import * as Ref from "effect/Ref";16import * as Ref from "effect/Ref";
14import * as Schema from "effect/Schema";17import * as Schema from "effect/Schema";
...@@ -21,10 +24,15 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";...@@ -21,10 +24,15 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2124
22import * as DesktopBackendManager from "./DesktopBackendManager.ts";25import * as DesktopBackendManager from "./DesktopBackendManager.ts";
23import * as DesktopObservability from "../app/DesktopObservability.ts";26import * as DesktopObservability from "../app/DesktopObservability.ts";
27import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
2428
25const decodeDesktopBackendBootstrap = Schema.decodeEffect(29const decodeDesktopBackendBootstrap = Schema.decodeEffect(
26 Schema.fromJsonString(DesktopBackendBootstrap),30 Schema.fromJsonString(DesktopBackendBootstrap),
27);31);
32const isBackendProcessError = Schema.is(DesktopBackendManager.BackendProcessError);
33const encodeDesktopTelemetryControl = Schema.encodeSync(
34 Schema.fromJsonString(DesktopTelemetryControlMessage),
35);
2836
29const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = {37const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = {
30 executablePath: "/electron",38 executablePath: "/electron",
...@@ -41,6 +49,8 @@ const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = {...@@ -41,6 +49,8 @@ const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = {
41 desktopBootstrapToken: "token",49 desktopBootstrapToken: "token",
42 tailscaleServeEnabled: false,50 tailscaleServeEnabled: false,
43 tailscaleServePort: 443,51 tailscaleServePort: 443,
52 desktopTelemetryFd: 4,
53 desktopTelemetryControlFd: 5,
44 },54 },
45 bootstrapDelivery: "fd3",55 bootstrapDelivery: "fd3",
46 extendEnv: true,56 extendEnv: true,
...@@ -52,14 +62,16 @@ const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = {...@@ -52,14 +62,16 @@ const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = {
52const configWithObservability: DesktopBackendBootstrapValue = {62const configWithObservability: DesktopBackendBootstrapValue = {
53 ...baseConfig.bootstrap,63 ...baseConfig.bootstrap,
54 tailscaleServeEnabled: true,64 tailscaleServeEnabled: true,
65 desktopTelemetryFd: 4,
55 otlpTracesUrl: "http://127.0.0.1:4318/v1/traces",66 otlpTracesUrl: "http://127.0.0.1:4318/v1/traces",
56};67};
5768
58function makeProcess(options?: {69function makeProcess(options?: {
59 readonly stdout?: Stream.Stream<Uint8Array>;70 readonly stdout?: Stream.Stream<Uint8Array, PlatformError.PlatformError>;
60 readonly stderr?: Stream.Stream<Uint8Array>;71 readonly stderr?: Stream.Stream<Uint8Array, PlatformError.PlatformError>;
61 readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>;72 readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>;
62 readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"];73 readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"];
74 readonly getOutputFd?: ChildProcessSpawner.ChildProcessHandle["getOutputFd"];
63}): ChildProcessSpawner.ChildProcessHandle {75}): ChildProcessSpawner.ChildProcessHandle {
64 return ChildProcessSpawner.makeHandle({76 return ChildProcessSpawner.makeHandle({
65 pid: ChildProcessSpawner.ProcessId(123),77 pid: ChildProcessSpawner.ProcessId(123),
...@@ -71,7 +83,7 @@ function makeProcess(options?: {...@@ -71,7 +83,7 @@ function makeProcess(options?: {
71 kill: options?.kill ?? (() => Effect.void),83 kill: options?.kill ?? (() => Effect.void),
72 stdin: Sink.drain,84 stdin: Sink.drain,
73 getInputFd: () => Sink.drain,85 getInputFd: () => Sink.drain,
74 getOutputFd: () => Stream.empty,86 getOutputFd: options?.getOutputFd ?? (() => Stream.empty),
75 unref: Effect.succeed(Effect.void),87 unref: Effect.succeed(Effect.void),
76 });88 });
77}89}
...@@ -112,7 +124,14 @@ interface MakeInstanceInput {...@@ -112,7 +124,14 @@ interface MakeInstanceInput {
112 failure: DesktopBackendManager.PreflightFailure,124 failure: DesktopBackendManager.PreflightFailure,
113 ) => Effect.Effect<boolean>;125 ) => Effect.Effect<boolean>;
114 readonly config?: DesktopBackendManager.DesktopBackendStartConfig;126 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 >;
116}135}
117136
118// Helper that constructs a primary backend instance using the factory137// Helper that constructs a primary backend instance using the factory
...@@ -122,8 +141,11 @@ interface MakeInstanceInput {...@@ -122,8 +141,11 @@ interface MakeInstanceInput {
122// to drive the instance's lifecycle.141// to drive the instance's lifecycle.
123function makeTestInstance(input: MakeInstanceInput) {142function makeTestInstance(input: MakeInstanceInput) {
124 const stubLog: DesktopObservability.DesktopBackendOutputLogShape = {143 const stubLog: DesktopObservability.DesktopBackendOutputLogShape = {
125 writeSessionBoundary: () => Effect.void,144 beginSession: () => Effect.void,
126 writeOutputChunk: () => Effect.void,145 writeOutputChunk: () => Effect.void,
146 persistFailureSnapshot: () => Effect.void,
147 persistFailure: () => Effect.void,
148 discardSession: Effect.void,
127 ...input.backendOutputLog,149 ...input.backendOutputLog,
128 };150 };
129 const servicesLayer = Layer.mergeAll(151 const servicesLayer = Layer.mergeAll(
...@@ -135,6 +157,16 @@ function makeTestInstance(input: MakeInstanceInput) {...@@ -135,6 +157,16 @@ function makeTestInstance(input: MakeInstanceInput) {
135 Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, {157 Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, {
136 forInstance: () => Effect.succeed(stubLog),158 forInstance: () => Effect.succeed(stubLog),
137 } satisfies DesktopObservability.DesktopBackendOutputLogFactory["Service"]),159 } 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 }),
138 );170 );
139171
140 const instance = DesktopBackendManager.makeBackendInstance({172 const instance = DesktopBackendManager.makeBackendInstance({
...@@ -150,11 +182,12 @@ function makeTestInstance(input: MakeInstanceInput) {...@@ -150,11 +182,12 @@ function makeTestInstance(input: MakeInstanceInput) {
150}182}
151183
152describe("DesktopBackendManager", () => {184describe("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", () =>
154 Effect.scoped(186 Effect.scoped(
155 Effect.gen(function* () {187 Effect.gen(function* () {
156 let spawnedCommand: ChildProcess.Command | undefined;188 let spawnedCommand: ChildProcess.Command | undefined;
157 let bootstrapJson = "";189 let bootstrapJson = "";
190 let telemetryJson = "";
158 let readyCount = 0;191 let readyCount = 0;
159 const ready = yield* Deferred.make<void>();192 const ready = yield* Deferred.make<void>();
160 const exited = yield* Queue.unbounded<void>();193 const exited = yield* Queue.unbounded<void>();
...@@ -169,6 +202,10 @@ describe("DesktopBackendManager", () => {...@@ -169,6 +202,10 @@ describe("DesktopBackendManager", () => {
169 if (fd3?.type === "input" && fd3.stream) {202 if (fd3?.type === "input" && fd3.stream) {
170 bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString);203 bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString);
171 }204 }
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 }
172 }209 }
173210
174 return makeProcess({211 return makeProcess({
...@@ -184,12 +221,14 @@ describe("DesktopBackendManager", () => {...@@ -184,12 +221,14 @@ describe("DesktopBackendManager", () => {
184 bootstrap: configWithObservability,221 bootstrap: configWithObservability,
185 },222 },
186 spawnerLayer,223 spawnerLayer,
224 desktopTelemetryStream: Stream.encodeText(
225 Stream.make('{"version":1,"type":"desktopTelemetryHello","electronPid":123}\n'),
226 ),
187 onReady: Effect.sync(() => {227 onReady: Effect.sync(() => {
188 readyCount += 1;228 readyCount += 1;
189 }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid),229 }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid),
190 backendOutputLog: {230 backendOutputLog: {
191 writeSessionBoundary: ({ phase }) =>231 persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid),
192 phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void,
193 },232 },
194 });233 });
195234
...@@ -210,16 +249,400 @@ describe("DesktopBackendManager", () => {...@@ -210,16 +249,400 @@ describe("DesktopBackendManager", () => {
210 assert.equal(spawnedCommand.options.stderr, "pipe");249 assert.equal(spawnedCommand.options.stderr, "pipe");
211 assert.equal(spawnedCommand.options.killSignal, "SIGTERM");250 assert.equal(spawnedCommand.options.killSignal, "SIGTERM");
212 assert.isDefined(spawnedCommand.options.forceKillAfter);251 assert.isDefined(spawnedCommand.options.forceKillAfter);
252 assert.equal(spawnedCommand.options.additionalFds?.fd4?.type, "input");
253 assert.equal(spawnedCommand.options.additionalFds?.fd5?.type, "output");
213 assert.equal(254 assert.equal(
214 Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)),255 Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)),
215 2_000,256 2_000,
216 );257 );
217258
218 assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability);259 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));
219 }),597 }),
220 ),598 ),
221 );599 );
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
223 it.effect("retries HTTP readiness before reporting the backend ready", () =>646 it.effect("retries HTTP readiness before reporting the backend ready", () =>
224 Effect.scoped(647 Effect.scoped(
225 Effect.gen(function* () {648 Effect.gen(function* () {
...@@ -256,8 +679,7 @@ describe("DesktopBackendManager", () => {...@@ -256,8 +679,7 @@ describe("DesktopBackendManager", () => {
256 readyCount += 1;679 readyCount += 1;
257 }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid),680 }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid),
258 backendOutputLog: {681 backendOutputLog: {
259 writeSessionBoundary: ({ phase }) =>682 persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid),
260 phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void,
261 },683 },
262 });684 });
263685
...@@ -285,9 +707,15 @@ describe("DesktopBackendManager", () => {...@@ -285,9 +707,15 @@ describe("DesktopBackendManager", () => {
285 let startCount = 0;707 let startCount = 0;
286 let closedCount = 0;708 let closedCount = 0;
287 const closed = yield* Deferred.make<void>();709 const closed = yield* Deferred.make<void>();
710 const teardownStarted = yield* Deferred.make<void>();
711 const finishTeardown = yield* Deferred.make<void>();
288 const startedPids = yield* Queue.unbounded<number>();712 const startedPids = yield* Queue.unbounded<number>();
289 const ready = yield* Deferred.make<void>();713 const ready = yield* Deferred.make<void>();
290 const backendReadyFlag = yield* Ref.make(false);714 const backendReadyFlag = yield* Ref.make(false);
715 let shutdownCount = 0;
716 let persistedFailureCount = 0;
717 let discardedSessionCount = 0;
718 let removedTelemetrySources = 0;
291719
292 const spawnerLayer = Layer.succeed(720 const spawnerLayer = Layer.succeed(
293 ChildProcessSpawner.ChildProcessSpawner,721 ChildProcessSpawner.ChildProcessSpawner,
...@@ -296,9 +724,16 @@ describe("DesktopBackendManager", () => {...@@ -296,9 +724,16 @@ describe("DesktopBackendManager", () => {
296 const scope = yield* Scope.Scope;724 const scope = yield* Scope.Scope;
297 startCount += 1;725 startCount += 1;
298 yield* Queue.offer(startedPids, 123);726 yield* Queue.offer(startedPids, 123);
299 const close = Effect.sync(() => {727 const close = Deferred.succeed(teardownStarted, undefined).pipe(
300 closedCount += 1;728 Effect.andThen(Deferred.await(finishTeardown)),
301 }).pipe(Effect.andThen(Deferred.succeed(closed, void 0)), Effect.asVoid);729 Effect.andThen(
730 Effect.sync(() => {
731 closedCount += 1;
732 }),
733 ),
734 Effect.andThen(Deferred.succeed(closed, void 0)),
735 Effect.asVoid,
736 );
302737
303 yield* Scope.addFinalizer(scope, close);738 yield* Scope.addFinalizer(scope, close);
304739
...@@ -316,7 +751,28 @@ describe("DesktopBackendManager", () => {...@@ -316,7 +751,28 @@ describe("DesktopBackendManager", () => {
316 Effect.andThen(Deferred.succeed(ready, void 0)),751 Effect.andThen(Deferred.succeed(ready, void 0)),
317 Effect.asVoid,752 Effect.asVoid,
318 ),753 ),
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 },
320 });776 });
321 assert.isTrue(Option.isNone(yield* instance.currentConfig));777 assert.isTrue(Option.isNone(yield* instance.currentConfig));
322778
...@@ -330,12 +786,21 @@ describe("DesktopBackendManager", () => {...@@ -330,12 +786,21 @@ describe("DesktopBackendManager", () => {
330 assert.equal(runningSnapshot.ready, true);786 assert.equal(runningSnapshot.ready, true);
331 assert.deepEqual(runningSnapshot.activePid, Option.some(123));787 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"));
334 assert.equal(startCount, 1);795 assert.equal(startCount, 1);
335 assert.equal(closedCount, 1);796 assert.equal(closedCount, 1);
797 assert.equal(persistedFailureCount, 0);
798 assert.equal(discardedSessionCount, 1);
799 assert.equal(removedTelemetrySources, 1);
336800
337 const stoppedSnapshot = yield* instance.snapshot;801 const stoppedSnapshot = yield* instance.snapshot;
338 assert.isFalse(yield* Ref.get(backendReadyFlag));802 assert.isFalse(yield* Ref.get(backendReadyFlag));
803 assert.equal(shutdownCount, 1);
339 assert.equal(stoppedSnapshot.desiredRunning, false);804 assert.equal(stoppedSnapshot.desiredRunning, false);
340 assert.equal(stoppedSnapshot.ready, false);805 assert.equal(stoppedSnapshot.ready, false);
341 assert.equal(Option.isNone(stoppedSnapshot.activePid), true);806 assert.equal(Option.isNone(stoppedSnapshot.activePid), true);
...@@ -343,6 +808,213 @@ describe("DesktopBackendManager", () => {...@@ -343,6 +808,213 @@ describe("DesktopBackendManager", () => {
343 ),808 ),
344 );809 );
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
346 it.effect("does not notify shutdown before the first start has prior state", () =>1018 it.effect("does not notify shutdown before the first start has prior state", () =>
347 Effect.scoped(1019 Effect.scoped(
348 Effect.gen(function* () {1020 Effect.gen(function* () {
...@@ -383,6 +1055,7 @@ describe("DesktopBackendManager", () => {...@@ -383,6 +1055,7 @@ describe("DesktopBackendManager", () => {
383 Effect.scoped(1055 Effect.scoped(
384 Effect.gen(function* () {1056 Effect.gen(function* () {
385 const starts = yield* Queue.unbounded<number>();1057 const starts = yield* Queue.unbounded<number>();
1058 const failures = yield* Queue.unbounded<string>();
386 let startCount = 0;1059 let startCount = 0;
3871060
388 const spawnerLayer = Layer.succeed(1061 const spawnerLayer = Layer.succeed(
...@@ -402,11 +1075,15 @@ describe("DesktopBackendManager", () => {...@@ -402,11 +1075,15 @@ describe("DesktopBackendManager", () => {
402 const instance = yield* makeTestInstance({1075 const instance = yield* makeTestInstance({
403 spawnerLayer,1076 spawnerLayer,
404 httpClientLayer: httpClientLayer(() => Effect.never),1077 httpClientLayer: httpClientLayer(() => Effect.never),
1078 backendOutputLog: {
1079 persistFailure: ({ details }) => Queue.offer(failures, details).pipe(Effect.asVoid),
1080 },
405 });1081 });
4061082
407 yield* instance.start;1083 yield* instance.start;
4081084
409 assert.equal(yield* Queue.take(starts), 1);1085 assert.equal(yield* Queue.take(starts), 1);
1086 assert.equal(yield* Queue.take(failures), "pid=123 code=1");
4101087
411 yield* TestClock.adjust(Duration.millis(499));1088 yield* TestClock.adjust(Duration.millis(499));
412 assert.equal(yield* Queue.size(starts), 0);1089 assert.equal(yield* Queue.size(starts), 0);
...@@ -657,6 +1334,13 @@ describe("DesktopBackendManager", () => {...@@ -657,6 +1334,13 @@ describe("DesktopBackendManager", () => {
657 yield* instance.start;1334 yield* instance.start;
6581335
659 assert.equal(yield* Queue.take(starts), 1);1336 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
661 yield* instance.start;1345 yield* instance.start;
662 assert.equal(yield* Queue.take(starts), 2);1346 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";...@@ -33,7 +33,6 @@ import * as FileSystem from "effect/FileSystem";
33import * as Option from "effect/Option";33import * as Option from "effect/Option";
34import * as PlatformError from "effect/PlatformError";34import * as PlatformError from "effect/PlatformError";
35import * as Ref from "effect/Ref";35import * as Ref from "effect/Ref";
36import * as Result from "effect/Result";
37import * as Schedule from "effect/Schedule";36import * as Schedule from "effect/Schedule";
38import * as Schema from "effect/Schema";37import * as Schema from "effect/Schema";
39import * as Semaphore from "effect/Semaphore";38import * as Semaphore from "effect/Semaphore";
...@@ -46,10 +45,13 @@ import {...@@ -46,10 +45,13 @@ import {
46 DesktopBackendBootstrap,45 DesktopBackendBootstrap,
47 type DesktopBackendBootstrap as DesktopBackendBootstrapValue,46 type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
48 PRIMARY_LOCAL_ENVIRONMENT_ID,47 PRIMARY_LOCAL_ENVIRONMENT_ID,
48 DesktopTelemetryControlMessage,
49 type DesktopTelemetryControlMessage as DesktopTelemetryControlMessageValue,
49} from "@t3tools/contracts";50} from "@t3tools/contracts";
50import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/httpReadiness";51import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/httpReadiness";
5152
52import * as DesktopObservability from "../app/DesktopObservability.ts";53import * as DesktopObservability from "../app/DesktopObservability.ts";
54import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
5355
54const INITIAL_RESTART_DELAY = Duration.millis(500);56const INITIAL_RESTART_DELAY = Duration.millis(500);
55const MAX_RESTART_DELAY = Duration.seconds(10);57const MAX_RESTART_DELAY = Duration.seconds(10);
...@@ -62,7 +64,10 @@ const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1);...@@ -62,7 +64,10 @@ const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1);
62const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100);64const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100);
63const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1);65const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1);
64const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(2);66const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(2);
67const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5);
65const BACKEND_READINESS_PATH = "/.well-known/t3/environment";68const BACKEND_READINESS_PATH = "/.well-known/t3/environment";
69const { logWarning: logBackendProcessWarning } =
70 DesktopObservability.makeComponentLogger("desktop-backend-process");
6671
67type BackendProcessLayerServices = ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient;72type BackendProcessLayerServices = ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient;
6873
...@@ -70,13 +75,17 @@ type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope;...@@ -70,13 +75,17 @@ type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope;
7075
71export type BackendProcessOutputStream = "stdout" | "stderr";76export type BackendProcessOutputStream = "stdout" | "stderr";
7277
73export type DesktopBackendBootstrapDelivery = "fd3" | "stdin";78export interface BackendProcessContext {
74
75export interface DesktopBackendStartConfig {
76 readonly executablePath: string;79 readonly executablePath: string;
77 readonly args: ReadonlyArray<string>;
78 readonly entryPath: string;80 readonly entryPath: string;
79 readonly cwd: string;81 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>;
80 readonly env: Record<string, string | undefined>;89 readonly env: Record<string, string | undefined>;
81 // When true the spawner merges the desktop process.env on top of `env`;90 // When true the spawner merges the desktop process.env on top of `env`;
82 // when false `env` is passed verbatim. WSL mode opts out so a leaking91 // when false `env` is passed verbatim. WSL mode opts out so a leaking
...@@ -106,55 +115,122 @@ export interface PreflightFailure {...@@ -106,55 +115,122 @@ export interface PreflightFailure {
106interface BackendProcessExit {115interface BackendProcessExit {
107 readonly code: Option.Option<number>;116 readonly code: Option.Option<number>;
108 readonly reason: string;117 readonly reason: string;
109 readonly result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>;
110}118}
111119
112export class BackendTimeoutError extends Schema.TaggedErrorClass<BackendTimeoutError>()(120const backendProcessContextSchema = {
113 "BackendTimeoutError",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",
114 {129 {
115 url: Schema.instanceOf(URL),130 ...backendProcessContextSchema,
131 readinessUrl: Schema.URL,
132 timeoutMs: Schema.Number,
133 cause: Schema.Defect(),
116 },134 },
117) {135) {
118 override get message() {136 override get message(): string {
119 return `Timed out waiting for backend readiness at ${this.url.href}.`;137 return `Timed out after ${this.timeoutMs}ms waiting for desktop backend readiness at ${this.readinessUrl.href}.`;
120 }138 }
121}139}
122140
123class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass<BackendProcessBootstrapEncodeError>()(141export class BackendProcessBootstrapEncodeError extends Schema.TaggedErrorClass<BackendProcessBootstrapEncodeError>()(
124 "BackendProcessBootstrapEncodeError",142 "BackendProcessBootstrapEncodeError",
125 {143 {
126 entryPath: Schema.String,144 ...backendProcessContextSchema,
127 cause: Schema.Defect(),145 cause: Schema.Defect(),
128 },146 },
129) {147) {
130 override get message() {148 override get message(): string {
131 return `Failed to encode the desktop backend bootstrap payload for ${this.entryPath}.`;149 return `Failed to encode the desktop backend bootstrap payload for ${this.entryPath}.`;
132 }150 }
133}151}
134152
135class BackendProcessSpawnError extends Schema.TaggedErrorClass<BackendProcessSpawnError>()(153export class BackendProcessSpawnError extends Schema.TaggedErrorClass<BackendProcessSpawnError>()(
136 "BackendProcessSpawnError",154 "BackendProcessSpawnError",
137 {155 {
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,
139 cause: Schema.Defect(),203 cause: Schema.Defect(),
140 },204 },
141) {205) {
142 override get message() {206 override get message(): string {
143 return `Failed to spawn the desktop backend process at ${this.executablePath}.`;207 return `Failed to read the exit status of desktop backend process ${this.pid}.`;
144 }208 }
145}209}
146210
147type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError;211export const BackendProcessError = Schema.Union([
212 BackendProcessBootstrapEncodeError,
213 BackendProcessSpawnError,
214 BackendProcessExitStatusError,
215]);
216export type BackendProcessError = typeof BackendProcessError.Type;
148217
149interface RunBackendProcessOptions extends DesktopBackendStartConfig {218interface RunBackendProcessOptions extends DesktopBackendStartConfig {
219 readonly desktopTelemetryStream: Stream.Stream<Uint8Array>;
220 readonly onDesktopTelemetryControl?: (
221 message: DesktopTelemetryControlMessageValue,
222 ) => Effect.Effect<void>;
150 readonly readinessTimeout?: Duration.Duration;223 readonly readinessTimeout?: Duration.Duration;
224 readonly outputDrainTimeout?: Duration.Duration;
151 readonly onStarted?: (pid: number) => Effect.Effect<void>;225 readonly onStarted?: (pid: number) => Effect.Effect<void>;
226 readonly onExitObserved?: () => Effect.Effect<void>;
152 readonly onReady?: () => Effect.Effect<void>;227 readonly onReady?: () => Effect.Effect<void>;
153 readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect<void>;228 readonly onReadinessFailure?: (error: BackendReadinessTimeoutError) => Effect.Effect<void>;
154 readonly onOutput?: (229 readonly onOutput?: (
155 streamName: BackendProcessOutputStream,230 streamName: BackendProcessOutputStream,
156 chunk: Uint8Array,231 chunk: Uint8Array,
157 ) => Effect.Effect<void>;232 ) => Effect.Effect<void, Error>;
233 readonly onOutputFailure?: (error: BackendProcessOutputError) => Effect.Effect<void>;
158}234}
159235
160export interface DesktopBackendSnapshot {236export interface DesktopBackendSnapshot {
...@@ -225,6 +301,8 @@ interface ActiveBackendRun {...@@ -225,6 +301,8 @@ interface ActiveBackendRun {
225 readonly scope: Scope.Closeable;301 readonly scope: Scope.Closeable;
226 readonly fiber: Option.Option<Fiber.Fiber<void, never>>;302 readonly fiber: Option.Option<Fiber.Fiber<void, never>>;
227 readonly pid: Option.Option<number>;303 readonly pid: Option.Option<number>;
304 readonly exitObserved: boolean;
305 readonly stopRequested: boolean;
228}306}
229307
230interface BackendManagerState {308interface BackendManagerState {
...@@ -266,76 +344,130 @@ const calculateRestartDelay = (attempt: number): Duration.Duration =>...@@ -266,76 +344,130 @@ const calculateRestartDelay = (attempt: number): Duration.Duration =>
266344
267const closeRun = (345const closeRun = (
268 run: ActiveBackendRun,346 run: ActiveBackendRun,
347 parentScope: Scope.Scope,
269 options?: { readonly timeout?: Duration.Duration },348 options?: { readonly timeout?: Duration.Duration },
270): Effect.Effect<void> => {349): Effect.Effect<boolean> => {
271 const waitForFiber = Option.match(run.fiber, {350 const waitForFiber = Option.match(run.fiber, {
272 onNone: () => Effect.void,351 onNone: () => Effect.void,
273 onSome: (fiber) => Fiber.await(fiber).pipe(Effect.asVoid),352 onSome: (fiber) => Fiber.await(fiber).pipe(Effect.asVoid),
274 });353 });
275 const close = Scope.close(run.scope, Exit.void).pipe(Effect.andThen(waitForFiber));354 const close = Scope.close(run.scope, Exit.void).pipe(Effect.andThen(waitForFiber));
355 const timeout = options?.timeout;
276356
277 return (357 if (!timeout) {
278 options?.timeout ? close.pipe(Effect.timeoutOption(options.timeout), Effect.asVoid) : close358 return close.pipe(Effect.as(true));
279 ).pipe(Effect.ignore);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 );
280};366};
281367
282const waitForHttpReady = (368export const waitForHttpReady = (
283 baseUrl: URL,369 options: BackendProcessContext & { readonly timeout: Duration.Duration },
284 timeout: Duration.Duration,370): Effect.Effect<void, BackendReadinessTimeoutError, HttpClient.HttpClient> => {
285): Effect.Effect<void, BackendTimeoutError, HttpClient.HttpClient> => {371 const readinessUrl = new URL(BACKEND_READINESS_PATH, options.httpBaseUrl);
286 const readinessUrl = new URL(BACKEND_READINESS_PATH, baseUrl);
287 return waitForHttpReadyShared({372 return waitForHttpReadyShared({
288 baseUrl: baseUrl.href,373 baseUrl: options.httpBaseUrl.href,
289 path: BACKEND_READINESS_PATH,374 path: BACKEND_READINESS_PATH,
290 timeoutMs: Duration.toMillis(timeout),375 timeoutMs: Duration.toMillis(options.timeout),
291 intervalMs: Duration.toMillis(DEFAULT_BACKEND_READINESS_INTERVAL),376 intervalMs: Duration.toMillis(DEFAULT_BACKEND_READINESS_INTERVAL),
292 probeTimeoutMs: Duration.toMillis(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT),377 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 }),
294 });388 });
295};389};
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
315function drainBackendOutput(391function drainBackendOutput(
392 context: BackendProcessContext & { readonly pid: number },
316 streamName: BackendProcessOutputStream,393 streamName: BackendProcessOutputStream,
317 stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>,394 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>,
319): Effect.Effect<void> {400): Effect.Effect<void> {
320 return stream.pipe(401 return stream.pipe(
321 Stream.runForEach((chunk) => onOutput(streamName, chunk)),402 Stream.mapError(
322 Effect.ignore,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 }),
323 );427 );
324}428}
325429
326const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap));430const 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* (
329 options: RunBackendProcessOptions,436 options: RunBackendProcessOptions,
330): Effect.fn.Return<BackendProcessExit, BackendProcessError, BackendProcessRunRequirements> {437): Effect.fn.Return<BackendProcessExit, BackendProcessError, BackendProcessRunRequirements> {
331 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;438 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
332 const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe(439 const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe(
333 Effect.mapError(440 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 }),
335 ),449 ),
336 );450 );
337 const onOutput = options.onOutput ?? (() => Effect.void);451 const onOutput = options.onOutput ?? (() => Effect.void);
338 const bootstrapStream = Stream.encodeText(Stream.make(`${bootstrapJson}\n`));452 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 }
339 const command = ChildProcess.make(options.executablePath, options.args, {471 const command = ChildProcess.make(options.executablePath, options.args, {
340 cwd: options.cwd,472 cwd: options.cwd,
341 env: options.env,473 env: options.env,
...@@ -350,34 +482,131 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* (...@@ -350,34 +482,131 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* (
350 // wsl.exe drops additional file descriptors when forwarding to the Linux482 // wsl.exe drops additional file descriptors when forwarding to the Linux
351 // side, so the WSL spawn path delivers the bootstrap envelope via stdin483 // side, so the WSL spawn path delivers the bootstrap envelope via stdin
352 // (`--bootstrap-fd 0`) instead.484 // (`--bootstrap-fd 0`) instead.
353 ...(options.bootstrapDelivery === "fd3"485 ...(options.bootstrapDelivery === "fd3" ? { additionalFds } : {}),
354 ? { additionalFds: { fd3: { type: "input" as const, stream: bootstrapStream } } }
355 : {}),
356 });486 });
357487
358 const handle = yield* spawner488 const handle = yield* spawner.spawn(command).pipe(
359 .spawn(command)489 Effect.mapError(
360 .pipe(490 (cause) =>
361 Effect.mapError(491 new BackendProcessSpawnError({
362 (cause) => new BackendProcessSpawnError({ executablePath: options.executablePath, cause }),492 executablePath: options.executablePath,
363 ),493 entryPath: options.entryPath,
364 );494 cwd: options.cwd,
495 httpBaseUrl: options.httpBaseUrl,
496 cause,
497 }),
498 ),
499 );
500 const outputFibers: Array<Fiber.Fiber<void, never>> = [];
365501
366 yield* options.onStarted?.(handle.pid) ?? Effect.void;502 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 }
367 if (options.captureOutput) {540 if (options.captureOutput) {
368 yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped);541 const outputContext = {
369 yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped);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 );
370 }565 }
371 yield* waitForHttpReady(566 yield* waitForHttpReady({
372 options.httpBaseUrl,567 executablePath: options.executablePath,
373 options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT,568 entryPath: options.entryPath,
374 ).pipe(569 cwd: options.cwd,
570 httpBaseUrl: options.httpBaseUrl,
571 timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT,
572 }).pipe(
375 Effect.tap(() => options.onReady?.() ?? Effect.void),573 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 }),
377 Effect.forkScoped,577 Effect.forkScoped,
378 );578 );
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;
381});610});
382611
383// Factory for one pooled backend instance. The returned instance owns612// Factory for one pooled backend instance. The returned instance owns
...@@ -394,12 +623,14 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (...@@ -394,12 +623,14 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
394 | ChildProcessSpawner.ChildProcessSpawner623 | ChildProcessSpawner.ChildProcessSpawner
395 | HttpClient.HttpClient624 | HttpClient.HttpClient
396 | DesktopObservability.DesktopBackendOutputLogFactory625 | DesktopObservability.DesktopBackendOutputLogFactory
626 | DesktopTelemetryPublisher.DesktopTelemetryPublisher
397 | Scope.Scope627 | Scope.Scope
398> {628> {
399 const parentScope = yield* Scope.Scope;629 const parentScope = yield* Scope.Scope;
400 const fileSystem = yield* FileSystem.FileSystem;630 const fileSystem = yield* FileSystem.FileSystem;
401 const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory;631 const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory;
402 const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id);632 const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id);
633 const desktopTelemetryPublisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher;
403 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;634 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
404 const httpClient = yield* HttpClient.HttpClient;635 const httpClient = yield* HttpClient.HttpClient;
405 const state = yield* Ref.make(initialState);636 const state = yield* Ref.make(initialState);
...@@ -444,6 +675,12 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (...@@ -444,6 +675,12 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
444 Effect.gen(function* () {675 Effect.gen(function* () {
445 const current = yield* Ref.get(state);676 const current = yield* Ref.get(state);
446 if (Option.isSome(current.active)) {677 if (Option.isSome(current.active)) {
678 if (!current.desiredRunning) {
679 yield* Ref.update(state, (latest) => ({
680 ...latest,
681 desiredRunning: true,
682 }));
683 }
447 return;684 return;
448 }685 }
449686
...@@ -462,6 +699,9 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (...@@ -462,6 +699,9 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
462 Effect.option,699 Effect.option,
463 );700 );
464 if (Option.isNone(config)) {701 if (Option.isNone(config)) {
702 if (current.desiredRunning) {
703 yield* scheduleRestart("failed to generate desktop backend configuration");
704 }
465 return;705 return;
466 }706 }
467 const entryExists = yield* fileSystem707 const entryExists = yield* fileSystem
...@@ -561,6 +801,8 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (...@@ -561,6 +801,8 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
561 scope: runScope,801 scope: runScope,
562 fiber: Option.none(),802 fiber: Option.none(),
563 pid: Option.none(),803 pid: Option.none(),
804 exitObserved: false,
805 stopRequested: false,
564 } satisfies ActiveBackendRun),806 } satisfies ActiveBackendRun),
565 nextRunId: latest.nextRunId + 1,807 nextRunId: latest.nextRunId + 1,
566 },808 },
...@@ -571,54 +813,70 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (...@@ -571,54 +813,70 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
571 ) {813 ) {
572 yield* mutex.withPermits(1)(814 yield* mutex.withPermits(1)(
573 Effect.gen(function* () {815 Effect.gen(function* () {
574 const { isCurrentRun, nextState, pid } = yield* Ref.modify(816 const { isCurrentRun, nextState, pid, exitObserved, stopRequested, wasReady } =
575 state,817 yield* Ref.modify(
576 (818 state,
577 latest,819 (
578 ): readonly [820 latest,
579 {821 ): readonly [
580 readonly isCurrentRun: boolean;822 {
581 readonly nextState: BackendManagerState;823 readonly isCurrentRun: boolean;
582 readonly pid: Option.Option<number>;824 readonly nextState: BackendManagerState;
583 },825 readonly pid: Option.Option<number>;
584 BackendManagerState,826 readonly exitObserved: boolean;
585 ] => {827 readonly stopRequested: boolean;
586 const currentRun = Option.getOrUndefined(latest.active);828 readonly wasReady: boolean;
587 if (currentRun?.id !== runId) {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 };
588 return [852 return [
589 {853 {
590 isCurrentRun: false,854 isCurrentRun: true,
591 nextState: latest,855 nextState: next,
592 pid: Option.none<number>(),856 pid: currentRun.pid,
857 exitObserved: currentRun.exitObserved,
858 stopRequested: currentRun.stopRequested,
859 wasReady: latest.ready,
593 },860 },
594 latest,861 next,
595 ] as const;862 ] as const;
596 }863 },
597864 );
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 );
613865
614 if (isCurrentRun) {866 if (isCurrentRun) {
867 yield* desktopTelemetryPublisher.removeControlSource(spec.id);
615 if (Option.isSome(pid)) {868 if (Option.isSome(pid)) {
616 yield* backendOutputLog.writeSessionBoundary({869 if (exitObserved && !stopRequested) {
617 phase: "END",870 yield* backendOutputLog.persistFailure({
618 details: `pid=${pid.value} ${reason}`,871 details: `pid=${pid.value} ${reason}`,
619 });872 });
873 } else {
874 yield* backendOutputLog.discardSession;
875 }
876 }
877 if (wasReady) {
878 yield* spec.onShutdown?.() ?? Effect.void;
620 }879 }
621 yield* spec.onShutdown?.() ?? Effect.void;
622 }880 }
623881
624 if (isCurrentRun && nextState.desiredRunning) {882 if (isCurrentRun && nextState.desiredRunning) {
...@@ -630,16 +888,23 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (...@@ -630,16 +888,23 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
630888
631 const program = runBackendProcess({889 const program = runBackendProcess({
632 ...config.value,890 ...config.value,
891 desktopTelemetryStream: desktopTelemetryPublisher.encoded,
892 onDesktopTelemetryControl: (message) =>
893 desktopTelemetryPublisher.handleControlForSource(spec.id, message),
633 onStarted: Effect.fn("desktop.backendInstance.onStarted")(function* (pid) {894 onStarted: Effect.fn("desktop.backendInstance.onStarted")(function* (pid) {
634 yield* updateActiveRun(runId, (run) => ({895 yield* updateActiveRun(runId, (run) => ({
635 ...run,896 ...run,
636 pid: Option.some(pid),897 pid: Option.some(pid),
637 }));898 }));
638 yield* backendOutputLog.writeSessionBoundary({899 yield* backendOutputLog.beginSession({
639 phase: "START",
640 details: `pid=${pid} port=${config.value.bootstrap.port} cwd=${config.value.cwd}`,900 details: `pid=${pid} port=${config.value.bootstrap.port} cwd=${config.value.cwd}`,
641 });901 });
642 }),902 }),
903 onExitObserved: () =>
904 updateActiveRun(runId, (run) => ({
905 ...run,
906 exitObserved: true,
907 })),
643 onReady: Effect.fn("desktop.backendInstance.onReady")(function* () {908 onReady: Effect.fn("desktop.backendInstance.onReady")(function* () {
644 const isCurrentRun = yield* Ref.modify(state, (latest) => {909 const isCurrentRun = yield* Ref.modify(state, (latest) => {
645 const activeRun = Option.getOrUndefined(latest.active);910 const activeRun = Option.getOrUndefined(latest.active);
...@@ -662,10 +927,16 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (...@@ -662,10 +927,16 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
662927
663 yield* spec.onReady?.(config.value.httpBaseUrl) ?? Effect.void;928 yield* spec.onReady?.(config.value.httpBaseUrl) ?? Effect.void;
664 }),929 }),
665 onReadinessFailure: (error) =>930 onReadinessFailure: Effect.fn("desktop.backendInstance.onReadinessFailure")(
666 logInstanceWarning("backend readiness check failed during bootstrap", {931 function* (error) {
667 error: error.message,932 yield* logInstanceWarning("backend readiness check failed during bootstrap", {
668 }),933 error: error.message,
934 });
935 yield* backendOutputLog.persistFailureSnapshot({
936 details: error.message,
937 });
938 },
939 ),
669 onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk),940 onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk),
670 }).pipe(941 }).pipe(
671 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),942 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
...@@ -750,41 +1021,92 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (...@@ -750,41 +1021,92 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
750 const stop = Effect.fn("desktop.backendInstance.stop")(function* (options?: {1021 const stop = Effect.fn("desktop.backendInstance.stop")(function* (options?: {
751 readonly timeout?: Duration.Duration;1022 readonly timeout?: Duration.Duration;
752 }) {1023 }) {
753 const { active, restartFiber } = yield* mutex.withPermits(1)(1024 const { active, restartFiber, notifyShutdown } = yield* mutex.withPermits(1)(
754 Effect.gen(function* () {1025 Effect.gen(function* () {
755 const result = yield* Ref.modify(state, (latest) => [1026 const result = yield* Ref.modify(state, (latest) => {
756 {1027 const active = Option.map(latest.active, (run) =>
757 active: latest.active,1028 run.exitObserved ? run : { ...run, stopRequested: true },
758 restartFiber: latest.restartFiber,1029 );
759 },1030 return [
760 {1031 {
761 ...latest,1032 active,
762 desiredRunning: false,1033 restartFiber: latest.restartFiber,
763 ready: false,1034 notifyShutdown: latest.ready,
764 active: Option.none<ActiveBackendRun>(),1035 },
765 restartFiber: Option.none<Fiber.Fiber<void, never>>(),1036 {
766 },1037 ...latest,
767 ]);1038 desiredRunning: false,
768 // Ignore failures from spec.onShutdown so a downstream throw1039 ready: false,
769 // can't abort the rest of stop(). Ref.modify above already1040 active,
770 // flipped state to "no active run / no restart fiber", and the1041 restartFiber: Option.none<Fiber.Fiber<void, never>>(),
771 // physical cleanup (Fiber.interrupt + closeRun) runs after the1042 },
772 // mutex releases. If onShutdown were allowed to propagate, both1043 ] as const;
773 // would be skipped and the child process + restart fiber would1044 });
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);
777 return result;1045 return result;
778 }),1046 }),
779 );1047 );
7801048
1049 if (notifyShutdown) {
1050 yield* (spec.onShutdown?.() ?? Effect.void).pipe(Effect.ignore);
1051 }
781 yield* Option.match(restartFiber, {1052 yield* Option.match(restartFiber, {
782 onNone: () => Effect.void,1053 onNone: () => Effect.void,
783 onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid),1054 onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid),
784 });1055 });
785 yield* Option.match(active, {1056 yield* Option.match(active, {
786 onNone: () => Effect.void,1057 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 }),
788 });1110 });
789 });1111 });
7901112
apps/desktop/src/backend/DesktopBackendPool.test.ts+14-1
...@@ -5,11 +5,13 @@ import * as FileSystem from "effect/FileSystem";...@@ -5,11 +5,13 @@ import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";6import * as Option from "effect/Option";
7import * as Ref from "effect/Ref";7import * as Ref from "effect/Ref";
8import * as Stream from "effect/Stream";
8import { HttpClient } from "effect/unstable/http";9import { HttpClient } from "effect/unstable/http";
9import { ChildProcessSpawner } from "effect/unstable/process";10import { ChildProcessSpawner } from "effect/unstable/process";
1011
11import * as DesktopObservability from "../app/DesktopObservability.ts";12import * as DesktopObservability from "../app/DesktopObservability.ts";
12import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";13import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
14import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
13import * as ElectronDialog from "../electron/ElectronDialog.ts";15import * as ElectronDialog from "../electron/ElectronDialog.ts";
14import * as DesktopWindow from "../window/DesktopWindow.ts";16import * as DesktopWindow from "../window/DesktopWindow.ts";
15import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";17import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
...@@ -56,10 +58,21 @@ function makePoolLayer(...@@ -56,10 +58,21 @@ function makePoolLayer(
56 Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, {58 Layer.succeed(DesktopObservability.DesktopBackendOutputLogFactory, {
57 forInstance: () =>59 forInstance: () =>
58 Effect.succeed({60 Effect.succeed({
59 writeSessionBoundary: () => Effect.void,61 beginSession: () => Effect.void,
60 writeOutputChunk: () => Effect.void,62 writeOutputChunk: () => Effect.void,
63 persistFailureSnapshot: () => Effect.void,
64 persistFailure: () => Effect.void,
65 discardSession: Effect.void,
61 } satisfies DesktopObservability.DesktopBackendOutputLogShape),66 } satisfies DesktopObservability.DesktopBackendOutputLogShape),
62 } satisfies DesktopObservability.DesktopBackendOutputLogFactory["Service"]),67 } 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 }),
63 Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, {76 Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, {
64 resolvePrimary: Effect.die("unexpected primary config resolve"),77 resolvePrimary: Effect.die("unexpected primary config resolve"),
65 resolvePrimaryLabel: Ref.get(labelRef),78 resolvePrimaryLabel: Ref.get(labelRef),
apps/desktop/src/backend/DesktopBackendPool.ts+3-1
...@@ -97,6 +97,7 @@ import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";...@@ -97,6 +97,7 @@ import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
97import * as DesktopBackendManager from "./DesktopBackendManager.ts";97import * as DesktopBackendManager from "./DesktopBackendManager.ts";
98import * as DesktopObservability from "../app/DesktopObservability.ts";98import * as DesktopObservability from "../app/DesktopObservability.ts";
99import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";99import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
100import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
100import * as DesktopWindow from "../window/DesktopWindow.ts";101import * as DesktopWindow from "../window/DesktopWindow.ts";
101import * as ElectronDialog from "../electron/ElectronDialog.ts";102import * as ElectronDialog from "../electron/ElectronDialog.ts";
102103
...@@ -176,7 +177,8 @@ export type BackendInstanceFactoryRequirements =...@@ -176,7 +177,8 @@ export type BackendInstanceFactoryRequirements =
176 | FileSystem.FileSystem177 | FileSystem.FileSystem
177 | ChildProcessSpawner.ChildProcessSpawner178 | ChildProcessSpawner.ChildProcessSpawner
178 | HttpClient.HttpClient179 | HttpClient.HttpClient
179 | DesktopObservability.DesktopBackendOutputLogFactory;180 | DesktopObservability.DesktopBackendOutputLogFactory
181 | DesktopTelemetryPublisher.DesktopTelemetryPublisher;
180182
181interface ActiveRegisteredInstance {183interface ActiveRegisteredInstance {
182 readonly _tag: "Active";184 readonly _tag: "Active";
apps/desktop/src/electron/ElectronApp.ts+2
...@@ -57,6 +57,7 @@ export class ElectronApp extends Context.Service<...@@ -57,6 +57,7 @@ export class ElectronApp extends Context.Service<
57 ) => Effect.Effect<void>;57 ) => Effect.Effect<void>;
58 readonly setAppUserModelId: (id: string) => Effect.Effect<void>;58 readonly setAppUserModelId: (id: string) => Effect.Effect<void>;
59 readonly requestSingleInstanceLock: Effect.Effect<boolean>;59 readonly requestSingleInstanceLock: Effect.Effect<boolean>;
60 readonly getAppMetrics: Effect.Effect<ReadonlyArray<Electron.ProcessMetric>>;
60 readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect<boolean>;61 readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect<boolean>;
61 readonly setAsDefaultProtocolClient: (62 readonly setAsDefaultProtocolClient: (
62 protocol: string,63 protocol: string,
...@@ -153,6 +154,7 @@ export const make = ElectronApp.of({...@@ -153,6 +154,7 @@ export const make = ElectronApp.of({
153 Electron.app.setAppUserModelId(id);154 Electron.app.setAppUserModelId(id);
154 }),155 }),
155 requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()),156 requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()),
157 getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()),
156 isDefaultProtocolClient: (protocol) =>158 isDefaultProtocolClient: (protocol) =>
157 Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)),159 Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)),
158 setAsDefaultProtocolClient: (protocol, path, args) =>160 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";...@@ -24,6 +24,7 @@ import * as DesktopIpc from "./ipc/DesktopIpc.ts";
24import * as ElectronApp from "./electron/ElectronApp.ts";24import * as ElectronApp from "./electron/ElectronApp.ts";
25import * as ElectronDialog from "./electron/ElectronDialog.ts";25import * as ElectronDialog from "./electron/ElectronDialog.ts";
26import * as ElectronMenu from "./electron/ElectronMenu.ts";26import * as ElectronMenu from "./electron/ElectronMenu.ts";
27import * as ElectronPowerMonitor from "./electron/ElectronPowerMonitor.ts";
27import * as ElectronProtocol from "./electron/ElectronProtocol.ts";28import * as ElectronProtocol from "./electron/ElectronProtocol.ts";
28import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts";29import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts";
29import * as ElectronShell from "./electron/ElectronShell.ts";30import * as ElectronShell from "./electron/ElectronShell.ts";
...@@ -52,6 +53,7 @@ import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts";...@@ -52,6 +53,7 @@ import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts";
52import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts";53import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts";
53import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";54import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";
54import * as DesktopState from "./app/DesktopState.ts";55import * as DesktopState from "./app/DesktopState.ts";
56import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts";
55import * as DesktopUpdates from "./updates/DesktopUpdates.ts";57import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
56import * as BrowserSession from "./preview/BrowserSession.ts";58import * as BrowserSession from "./preview/BrowserSession.ts";
57import * as PreviewManager from "./preview/Manager.ts";59import * as PreviewManager from "./preview/Manager.ts";
...@@ -113,6 +115,7 @@ const electronLayer = Layer.mergeAll(...@@ -113,6 +115,7 @@ const electronLayer = Layer.mergeAll(
113 ElectronApp.layer,115 ElectronApp.layer,
114 ElectronDialog.layer,116 ElectronDialog.layer,
115 ElectronMenu.layer,117 ElectronMenu.layer,
118 ElectronPowerMonitor.layer,
116 ElectronProtocol.layer,119 ElectronProtocol.layer,
117 ElectronSafeStorage.layer,120 ElectronSafeStorage.layer,
118 ElectronShell.layer,121 ElectronShell.layer,
...@@ -160,6 +163,7 @@ const desktopBackendLayer = DesktopBackendPool.layer.pipe(...@@ -160,6 +163,7 @@ const desktopBackendLayer = DesktopBackendPool.layer.pipe(
160 Layer.provideMerge(DesktopAppIdentity.layer),163 Layer.provideMerge(DesktopAppIdentity.layer),
161 Layer.provideMerge(DesktopBackendConfiguration.layer),164 Layer.provideMerge(DesktopBackendConfiguration.layer),
162 Layer.provideMerge(DesktopWslEnvironment.layer),165 Layer.provideMerge(DesktopWslEnvironment.layer),
166 Layer.provideMerge(DesktopTelemetryPublisher.layer),
163 Layer.provideMerge(desktopWindowLayer),167 Layer.provideMerge(desktopWindowLayer),
164);168);
165169
apps/desktop/src/preview/Manager.test.ts+86
...@@ -982,6 +982,92 @@ describe("PreviewManager", () => {...@@ -982,6 +982,92 @@ describe("PreviewManager", () => {
982 ),982 ),
983 );983 );
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
985 effectIt.effect("shares background frame capture between recording and picture-in-picture", () =>1071 effectIt.effect("shares background frame capture between recording and picture-in-picture", () =>
986 withManager((manager) =>1072 withManager((manager) =>
987 Effect.gen(function* () {1073 Effect.gen(function* () {
apps/desktop/src/preview/Manager.ts+55-6
...@@ -671,10 +671,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -671,10 +671,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
671 ),671 ),
672 );672 );
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
674 const pushBounded = <A>(buffer: ReadonlyArray<A>, entry: A): ReadonlyArray<A> =>681 const pushBounded = <A>(buffer: ReadonlyArray<A>, entry: A): ReadonlyArray<A> =>
675 [...buffer, entry].slice(-DIAGNOSTIC_BUFFER_LIMIT);682 [...buffer, entry].slice(-DIAGNOSTIC_BUFFER_LIMIT);
676683
677 const captureDiagnosticMessage = Effect.fn("PreviewManager.captureDiagnosticMessage")(function* (684 const captureDiagnosticMessage = Effect.fnUntraced(function* (
678 webContentsId: number,685 webContentsId: number,
679 method: string,686 method: string,
680 params: Record<string, unknown>,687 params: Record<string, unknown>,
...@@ -852,11 +859,53 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -852,11 +859,53 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
852 const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () {859 const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () {
853 const semaphore = yield* Semaphore.make(1);860 const semaphore = yield* Semaphore.make(1);
854 const scope = yield* Scope.fork(parentScope, "sequential");861 const scope = yield* Scope.fork(parentScope, "sequential");
855 const handleDebuggerMessage = Effect.fn("PreviewManager.handleDebuggerMessage")(862 const handleDebuggerMessage = Effect.fnUntraced(function* (
856 function* (method: string, params: Record<string, unknown>) {863 method: string,
857 yield* captureDiagnosticMessage(wc.id, method, params);864 params: Record<string, unknown>,
858 },865 ) {
859 );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 });
860 const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => {909 const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => {
861 runFork(handleDebuggerMessage(method, params));910 runFork(handleDebuggerMessage(method, params));
862 };911 };
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, {...@@ -40,6 +40,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
40 setAboutPanelOptions: () => Effect.void,40 setAboutPanelOptions: () => Effect.void,
41 setAppUserModelId: () => Effect.void,41 setAppUserModelId: () => Effect.void,
42 requestSingleInstanceLock: Effect.succeed(true),42 requestSingleInstanceLock: Effect.succeed(true),
43 getAppMetrics: Effect.succeed([]),
43 isDefaultProtocolClient: () => Effect.succeed(false),44 isDefaultProtocolClient: () => Effect.succeed(false),
44 setAsDefaultProtocolClient: () => Effect.succeed(true),45 setAsDefaultProtocolClient: () => Effect.succeed(true),
45 setDesktopName: () => Effect.void,46 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";...@@ -5,6 +5,10 @@ import * as Layer from "effect/Layer";
5import { Atom } from "effect/unstable/reactivity";5import { Atom } from "effect/unstable/reactivity";
66
7import { runtimeContextLayer } from "../lib/runtime";7import { runtimeContextLayer } from "../lib/runtime";
8import {
9 mobileBackgroundActivityObserverLayer,
10 mobileBackgroundActivityReporterLayer,
11} from "./background-activity";
8import { connectionPlatformLayer } from "./platform";12import { connectionPlatformLayer } from "./platform";
913
10const providedConnectionPlatformLayer = connectionPlatformLayer.pipe(14const providedConnectionPlatformLayer = connectionPlatformLayer.pipe(
...@@ -17,10 +21,22 @@ type ConnectionLayerSource =...@@ -17,10 +21,22 @@ type ConnectionLayerSource =
17 | typeof Connection.layer21 | typeof Connection.layer
18 | typeof snapshotLoaderLayer22 | typeof snapshotLoaderLayer
19 | typeof runtimeContextLayer23 | typeof runtimeContextLayer
20 | typeof connectionPlatformLayer;24 | typeof connectionPlatformLayer
25 | typeof mobileBackgroundActivityObserverLayer
26 | typeof mobileBackgroundActivityReporterLayer;
2127
22const connectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe(28const providedClientConnectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe(
23 Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)),29 Layer.provideMerge(
30 Layer.mergeAll(
31 runtimeContextLayer,
32 providedConnectionPlatformLayer,
33 mobileBackgroundActivityObserverLayer,
34 ),
35 ),
36);
37
38const connectionLayer = mobileBackgroundActivityReporterLayer.pipe(
39 Layer.provideMerge(providedClientConnectionLayer),
24);40);
2541
26export const connectionAtomRuntime: Atom.AtomRuntime<42export 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) => {...@@ -41,7 +41,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
41 const defaultObservabilityConfig = {41 const defaultObservabilityConfig = {
42 traceMinLevel: "Info",42 traceMinLevel: "Info",
43 traceTimingEnabled: true,43 traceTimingEnabled: true,
44 traceBatchWindowMs: 200,44 traceBatchWindowMs: 1_000,
45 traceMaxBytes: 10 * 1024 * 1024,45 traceMaxBytes: 10 * 1024 * 1024,
46 traceMaxFiles: 10,46 traceMaxFiles: 10,
47 otlpTracesUrl: undefined,47 otlpTracesUrl: undefined,
...@@ -286,6 +286,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -286,6 +286,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
286 t3Home: baseDir,286 t3Home: baseDir,
287 noBrowser: true,287 noBrowser: true,
288 desktopBootstrapToken: "desktop-token",288 desktopBootstrapToken: "desktop-token",
289 desktopTelemetryFd: 4,
290 desktopTelemetryControlFd: 5,
289 tailscaleServeEnabled: false,291 tailscaleServeEnabled: false,
290 tailscaleServePort: 443,292 tailscaleServePort: 443,
291 otlpTracesUrl: "http://localhost:4318/v1/traces",293 otlpTracesUrl: "http://localhost:4318/v1/traces",
...@@ -341,12 +343,17 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -341,12 +343,17 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
341 noBrowser: true,343 noBrowser: true,
342 startupPresentation: "browser",344 startupPresentation: "browser",
343 desktopBootstrapToken: "desktop-token",345 desktopBootstrapToken: "desktop-token",
346 desktopTelemetryFd: 4,
347 desktopTelemetryControlFd: 5,
348 resourceMonitorPath: undefined,
344 autoBootstrapProjectFromCwd: false,349 autoBootstrapProjectFromCwd: false,
345 logWebSocketEvents: false,350 logWebSocketEvents: false,
346 tailscaleServeEnabled: false,351 tailscaleServeEnabled: false,
347 tailscaleServePort: 443,352 tailscaleServePort: 443,
348 });353 });
349 assert.equal(join(baseDir, "userdata"), resolved.stateDir);354 assert.equal(join(baseDir, "userdata"), resolved.stateDir);
355 assert.equal(resolved.desktopTelemetryFd, 4);
356 assert.equal(resolved.desktopTelemetryControlFd, 5);
350 }),357 }),
351 );358 );
352359
apps/server/src/cli/config.ts+7-1
...@@ -85,7 +85,7 @@ const EnvServerConfig = Config.all({...@@ -85,7 +85,7 @@ const EnvServerConfig = Config.all({
85 ),85 ),
86 traceMaxBytes: Config.int("T3CODE_TRACE_MAX_BYTES").pipe(Config.withDefault(10 * 1024 * 1024)),86 traceMaxBytes: Config.int("T3CODE_TRACE_MAX_BYTES").pipe(Config.withDefault(10 * 1024 * 1024)),
87 traceMaxFiles: Config.int("T3CODE_TRACE_MAX_FILES").pipe(Config.withDefault(10)),87 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)),
89 otlpTracesUrl: Config.string("T3CODE_OTLP_TRACES_URL").pipe(89 otlpTracesUrl: Config.string("T3CODE_OTLP_TRACES_URL").pipe(
90 Config.option,90 Config.option,
91 Config.map(Option.getOrUndefined),91 Config.map(Option.getOrUndefined),
...@@ -303,6 +303,9 @@ export const resolveServerConfig = (...@@ -303,6 +303,9 @@ export const resolveServerConfig = (
303 () => mode === "desktop",303 () => mode === "desktop",
304 );304 );
305 const desktopBootstrapToken = bootstrap?.desktopBootstrapToken;305 const desktopBootstrapToken = bootstrap?.desktopBootstrapToken;
306 const desktopTelemetryFd = bootstrap?.desktopTelemetryFd;
307 const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd;
308 const resourceMonitorPath = bootstrap?.resourceMonitorPath;
306 const autoBootstrapProjectFromCwd = Option.getOrElse(309 const autoBootstrapProjectFromCwd = Option.getOrElse(
307 resolveOptionPrecedence(310 resolveOptionPrecedence(
308 Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd),311 Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd),
...@@ -376,6 +379,9 @@ export const resolveServerConfig = (...@@ -376,6 +379,9 @@ export const resolveServerConfig = (
376 noBrowser,379 noBrowser,
377 startupPresentation,380 startupPresentation,
378 desktopBootstrapToken,381 desktopBootstrapToken,
382 desktopTelemetryFd,
383 desktopTelemetryControlFd,
384 resourceMonitorPath,
379 autoBootstrapProjectFromCwd,385 autoBootstrapProjectFromCwd,
380 logWebSocketEvents,386 logWebSocketEvents,
381 tailscaleServeEnabled,387 tailscaleServeEnabled,
apps/server/src/config.ts+6
...@@ -76,6 +76,9 @@ export class ServerConfig extends Context.Service<...@@ -76,6 +76,9 @@ export class ServerConfig extends Context.Service<
76 readonly noBrowser: boolean;76 readonly noBrowser: boolean;
77 readonly startupPresentation: StartupPresentation;77 readonly startupPresentation: StartupPresentation;
78 readonly desktopBootstrapToken: string | undefined;78 readonly desktopBootstrapToken: string | undefined;
79 readonly desktopTelemetryFd?: number | undefined;
80 readonly desktopTelemetryControlFd?: number | undefined;
81 readonly resourceMonitorPath?: string | undefined;
79 readonly autoBootstrapProjectFromCwd: boolean;82 readonly autoBootstrapProjectFromCwd: boolean;
80 readonly logWebSocketEvents: boolean;83 readonly logWebSocketEvents: boolean;
81 readonly tailscaleServeEnabled: boolean;84 readonly tailscaleServeEnabled: boolean;
...@@ -186,6 +189,9 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (...@@ -186,6 +189,9 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (
186 port: 0,189 port: 0,
187 host: undefined,190 host: undefined,
188 desktopBootstrapToken: undefined,191 desktopBootstrapToken: undefined,
192 desktopTelemetryFd: undefined,
193 desktopTelemetryControlFd: undefined,
194 resourceMonitorPath: undefined,
189 staticDir: undefined,195 staticDir: undefined,
190 devUrl,196 devUrl,
191 devAllowedOrigins: [],197 devAllowedOrigins: [],
apps/server/src/diagnostics/ProcessDiagnostics.test.ts+235-233
...@@ -1,291 +1,293 @@...@@ -1,291 +1,293 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import type {
3 DesktopHostTelemetrySnapshot,
4 ResourceMonitorSnapshotEvent,
5} from "@t3tools/contracts";
2import * as DateTime from "effect/DateTime";6import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";9import * as Option from "effect/Option";
6import * as Sink from "effect/Sink";
7import * as Stream from "effect/Stream";10import * 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";
11import * as ProcessDiagnostics from "./ProcessDiagnostics.ts";16import * 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: {34function makeTelemetryLayer(
16 readonly stdout?: string;35 snapshot: ResourceMonitorSnapshotEvent,
17 readonly stderr?: string;36 desktopSnapshot?: DesktopHostTelemetrySnapshot,
18 readonly code?: number;37) {
19}) {38 const nativeLayer = NativeTelemetryClient.layerTest({
20 return ChildProcessSpawner.makeHandle({39 sampleNow: Effect.succeed({ generation: 0, snapshot }),
21 pid: ChildProcessSpawner.ProcessId(1),40 health: Effect.succeed({
22 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)),41 status: "healthy",
23 isRunning: Effect.succeed(false),42 hello: Option.none(),
24 kill: () => Effect.void,43 lastSampleAt: Option.some(DateTime.makeUnsafe(snapshot.sampledAtUnixMs)),
25 unref: Effect.succeed(Effect.void),44 lastError: Option.none(),
26 stdin: Sink.drain,45 restartCount: 0,
27 stdout: Stream.make(encoder.encode(result.stdout ?? "")),46 sampleIntervalMs: 1_000,
28 stderr: Stream.make(encoder.encode(result.stderr ?? "")),47 }),
29 all: Stream.empty,
30 getInputFd: () => Sink.drain,
31 getOutputFd: () => Stream.empty,
32 });48 });
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 );
33}62}
3463
35describe("ProcessDiagnostics", () => {64describe("ProcessDiagnostics", () => {
36 it.effect("parses POSIX ps rows with full commands", () =>65 it.effect("projects live process data from resource telemetry", () =>
37 Effect.sync(() => {66 Effect.gen(function* () {
38 const rows = ProcessDiagnostics.parsePosixProcessRows(67 const snapshot = makeNativeSnapshot([
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([
46 {68 {
47 pid: 10,69 pid: process.pid,
48 ppid: 1,70 ppid: 1,
49 pgid: 10,71 startTimeMs: 1_000,
50 status: "Ss",72 runTimeMs: 60_000,
73 name: "node",
74 command: "t3 server",
75 status: "Running",
51 cpuPercent: 0,76 cpuPercent: 0,
52 rssBytes: 1024 * 1024,77 cpuTimeMs: 100,
53 elapsed: "01:02.03",78 residentBytes: 1_024,
54 command: "/usr/bin/node server.js",79 virtualBytes: 2_048,
80 ioReadBytes: 100,
81 ioWriteBytes: 200,
82 ioSemantics: "storage",
55 },83 },
56 {84 {
57 pid: 11,85 pid: 4_242,
58 ppid: 10,86 ppid: process.pid,
59 pgid: 10,87 startTimeMs: 2_000,
60 status: "S+",88 runTimeMs: 4_000,
61 cpuPercent: 12.5,89 name: "agent",
62 rssBytes: 20480 * 1024,90 command: "codex app-server",
63 elapsed: "00:04",91 status: "Running",
64 command: "codex app-server --config /tmp/one two",92 cpuPercent: 1.5,
93 cpuTimeMs: 60,
94 residentBytes: 2_048,
95 virtualBytes: 4_096,
96 ioReadBytes: 300,
97 ioWriteBytes: 400,
98 ioSemantics: "storage",
65 },99 },
66 ]);100 ]);
67 }),101 const telemetryLayer = makeTelemetryLayer(snapshot);
68 );102 const layer = ProcessDiagnostics.layer.pipe(Layer.provideMerge(telemetryLayer));
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 });
128103
129 expect(diagnostics.serverPid).toBe(100);104 const diagnostics = yield* Effect.gen(function* () {
130 expect(DateTime.formatIso(diagnostics.readAt)).toBe("2026-05-05T10:00:00.000Z");105 const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;
131 expect(diagnostics.processCount).toBe(2);106 return yield* processDiagnostics.read;
132 expect(diagnostics.totalRssBytes).toBe(6_000);107 }).pipe(Effect.provide(layer));
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 );
140108
141 it.effect("preserves ascending sibling order for nested descendants", () =>109 expect(diagnostics.processes.map((process) => process.pid)).toEqual([4242]);
142 Effect.sync(() => {110 expect(diagnostics.processes[0]?.startTimeMs).toBe(2_000);
143 const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({111 expect(diagnostics.processes[0]?.cpuPercent).toBe(1.5);
144 serverPid: 100,112 expect(diagnostics.processes[0]?.rssBytes).toBe(2_048);
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]);
181 }),113 }),
182 );114 );
183115
184 it.effect("queries processes through the ChildProcessSpawner service", () =>116 it.effect("rejects stale process identities before signaling", () =>
185 Effect.gen(function* () {117 Effect.gen(function* () {
186 const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> =118 const snapshot = makeNativeSnapshot([]);
187 [];119 const telemetryLayer = makeTelemetryLayer(snapshot);
188 const spawnerLayer = Layer.succeed(120 const layer = ProcessDiagnostics.layer.pipe(Layer.provide(telemetryLayer));
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));
207121
208 const diagnostics = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe(122 const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe(
209 Effect.flatMap((pd) => pd.read),123 Effect.flatMap((processDiagnostics) =>
124 processDiagnostics.signal({
125 pid: 4_242,
126 startTimeMs: 2_000,
127 signal: "SIGINT",
128 }),
129 ),
210 Effect.provide(layer),130 Effect.provide(layer),
211 );131 );
212132
213 expect(diagnostics.processes.map((process) => process.pid)).toEqual([4242]);133 expect(result).toEqual({
214 expect(commands).toEqual([134 pid: 4242,
215 {135 signal: "SIGINT",
216 command: "ps",136 signaled: false,
217 args: ["-axo", "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="],137 message: Option.some("Process 4242 no longer matches the selected process identity."),
218 },138 });
219 ]);
220 }),139 }),
221 );140 );
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", () =>
224 Effect.gen(function* () {143 Effect.gen(function* () {
225 const spawnerLayer = Layer.succeed(144 const snapshot = makeNativeSnapshot([
226 ChildProcessSpawner.ChildProcessSpawner,145 {
227 ChildProcessSpawner.make(() =>146 pid: 4_242,
228 Effect.succeed(147 ppid: process.pid,
229 mockHandle({148 startTimeMs: 2_000,
230 code: 17,149 runTimeMs: 4_000,
231 stdout: "partial process output",150 name: "agent",
232 stderr: "process access denied",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"),
233 }),177 }),
234 ),178 ),
235 ),179 validateProcessIdentity: () => Effect.die("unused"),
180 retry: Effect.die("unused"),
181 }),
236 );182 );
183 const layer = ProcessDiagnostics.layer.pipe(Layer.provide(telemetryLayer));
237184
238 const error = yield* ProcessDiagnostics.readProcessRows.pipe(185 const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe(
239 Effect.provide(spawnerLayer),186 Effect.flatMap((processDiagnostics) =>
240 Effect.provideService(HostProcessPlatform, "linux"),187 processDiagnostics.signal({
241 Effect.flip,188 pid: 4_242,
189 startTimeMs: 2_000,
190 signal: "SIGINT",
191 }),
192 ),
193 Effect.provide(layer),
242 );194 );
243195
244 expect(error).toMatchObject({196 expect(result).toEqual({
245 _tag: "ProcessDiagnosticsQueryFailedError",197 pid: 4_242,
246 command: "ps",198 signal: "SIGINT",
247 argCount: 2,199 signaled: false,
248 cwd: process.cwd(),200 message: Option.some(
249 exitCode: 17,201 "Could not refresh process 4242; refusing to signal a stale identity.",
250 stdoutBytes: 22,202 ),
251 stderrBytes: 21,
252 stdoutTruncated: false,
253 stderrTruncated: false,
254 });203 });
255 expect(error.message).toBe(
256 `Process diagnostics query 'ps' failed with exit code 17 in '${process.cwd()}'.`,
257 );
258 }),204 }),
259 );205 );
260206
261 it.effect("does not allow signaling the diagnostics query process", () =>207 it.effect("rejects Electron processes as signal targets", () =>
262 Effect.gen(function* () {208 Effect.gen(function* () {
263 const spawnerLayer = Layer.succeed(209 const sampledAtUnixMs = DateTime.toEpochMillis(
264 ChildProcessSpawner.ChildProcessSpawner,210 DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"),
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 ),
275 );211 );
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
278 const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe(265 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 ),
280 Effect.provide(layer),273 Effect.provide(layer),
281 );274 );
282275
283 expect(result).toEqual({276 expect(result).toEqual({
284 pid: 4242,277 pid: 4_242,
285 signal: "SIGINT",278 signal: "SIGKILL",
286 signaled: false,279 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."),
288 });281 });
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);
289 }),291 }),
290 );292 );
291});293});
apps/server/src/diagnostics/ProcessDiagnostics.ts+116-523
...@@ -1,105 +1,20 @@...@@ -1,105 +1,20 @@
1import type {1import type {
2 ResourceTelemetryProcessCategory,
2 ServerProcessDiagnosticsEntry,3 ServerProcessDiagnosticsEntry,
3 ServerProcessDiagnosticsResult,4 ServerProcessDiagnosticsResult,
4 ServerProcessSignal,5 ServerProcessSignal,
5 ServerSignalProcessResult,6 ServerSignalProcessResult,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
7import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
8import * as Context from "effect/Context";8import * as Context from "effect/Context";
9import * as DateTime from "effect/DateTime";
10import * as Duration from "effect/Duration";
11import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";11import * as Option from "effect/Option";
14import * as Schema from "effect/Schema";12import * 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 {16export class ProcessSignalFailed extends Schema.TaggedErrorClass<ProcessSignalFailed>()(
21 readonly pid: number;17 "ProcessSignalFailed",
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",
103 {18 {
104 pid: Schema.Number,19 pid: Schema.Number,
105 signal: Schema.String,20 signal: Schema.String,
...@@ -111,458 +26,136 @@ class ProcessDiagnosticsSignalFailedError extends Schema.TaggedErrorClass<Proces...@@ -111,458 +26,136 @@ class ProcessDiagnosticsSignalFailedError extends Schema.TaggedErrorClass<Proces
111 }26 }
112}27}
11328
114const ProcessDiagnosticsError = Schema.Union([29export class ProcessDiagnostics extends Context.Service<
115 ProcessDiagnosticsQueryTimeoutError,30 ProcessDiagnostics,
116 ProcessDiagnosticsQueryFailedError,31 {
117 ProcessDiagnosticsServerProcessSignalError,32 readonly read: Effect.Effect<ServerProcessDiagnosticsResult>;
118 ProcessDiagnosticsNotDescendantError,33 readonly signal: (input: {
119 ProcessDiagnosticsSignalFailedError,34 readonly pid: number;
120]);35 readonly startTimeMs: number;
121type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type;36 readonly signal: ServerProcessSignal;
122const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError);37 }) => Effect.Effect<ServerSignalProcessResult>;
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 })));
290 }38 }
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")}`;
293}49}
29450
295export function isDiagnosticsQueryProcess(row: ProcessRow, serverPid: number): boolean {51function canSignalCategory(category: ResourceTelemetryProcessCategory): boolean {
296 if (row.ppid !== serverPid) return false;
297
298 const command = row.command.trim();
299 return (52 return (
300 /(?:^|[/\\])ps\s+-axo\s+pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=/.test(command) ||53 category === "server-child" || category === "provider-root" || category === "terminal-root"
301 (/\bpowershell(?:\.exe)?\b/i.test(command) &&
302 /\bGet-CimInstance\s+Win32_Process\b/i.test(command))
303 );54 );
304}55}
30556
306function makeResult(input: {57export const make = Effect.fn("makeProcessDiagnostics")(function* () {
307 readonly serverPid: number;58 const telemetry = yield* ResourceTelemetry.ResourceTelemetry;
308 readonly rows: ReadonlyArray<ProcessRow>;59 const refreshedTelemetry = telemetry.refresh.pipe(Effect.catch(() => telemetry.latest));
309 readonly readAt: DateTime.Utc;60 const read: ProcessDiagnostics["Service"]["read"] = refreshedTelemetry.pipe(
310 readonly error?: string;61 Effect.map((snapshot) => {
311}): ServerProcessDiagnosticsResult {62 const processes = snapshot.processes
312 const readAt = input.readAt;63 .filter((entry) => canSignalCategory(entry.category))
313 const rows = input.rows.filter((row) => !isDiagnosticsQueryProcess(row, input.serverPid));64 .map(
314 const processes = buildDescendantEntries(rows, input.serverPid);65 (entry): ServerProcessDiagnosticsEntry => ({
315 const totalRssBytes = processes.reduce((total, process) => total + process.rssBytes, 0);66 pid: entry.identity.pid,
316 const totalCpuPercent = processes.reduce((total, process) => total + process.cpuPercent, 0);67 startTimeMs: entry.identity.startTimeMs,
31768 ppid: entry.ppid,
318 return {69 pgid: Option.none(),
319 serverPid: input.serverPid,70 status: entry.status || "Unknown",
320 readAt,71 cpuPercent: entry.cpuPercent,
321 processCount: processes.length,72 rssBytes: entry.residentBytes,
322 totalRssBytes,73 elapsed: formatElapsed(entry.runTimeMs),
323 totalCpuPercent,74 command: entry.command || entry.name || "unknown",
324 processes,75 depth: Math.max(0, entry.depth - 1),
325 error: input.error ? Option.some({ message: input.error }) : Option.none(),76 childPids: entry.childPids,
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,
407 }),77 }),
408 ),78 );
409 );79 return {
410});80 serverPid: process.pid,
41181 readAt: snapshot.readAt,
412function readPosixProcessRows(): Effect.Effect<82 processCount: processes.length,
413 ReadonlyArray<ProcessRow>,83 totalRssBytes: processes.reduce((total, entry) => total + entry.rssBytes, 0),
414 ProcessDiagnosticsError,84 totalCpuPercent: processes.reduce((total, entry) => total + entry.cpuPercent, 0),
415 ChildProcessSpawner.ChildProcessSpawner85 processes,
416> {86 error: Option.map(snapshot.health.native.lastError, (message) => ({ message })),
417 return runProcess({87 };
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 );
514 }),88 }),
515 );89 );
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
537 const signal: ProcessDiagnostics["Service"]["signal"] = Effect.fn("ProcessDiagnostics.signal")(91 const signal: ProcessDiagnostics["Service"]["signal"] = Effect.fn("ProcessDiagnostics.signal")(
538 function* (input) {92 function* (input) {
539 return yield* assertDescendantPid(input.pid).pipe(93 if (input.pid === process.pid) {
540 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),94 return {
541 Effect.flatMap(() =>95 pid: input.pid,
542 Effect.try({96 signal: input.signal,
543 try: () => {97 signaled: false,
544 process.kill(input.pid, input.signal);98 message: Option.some("Refusing to signal the T3 server process."),
545 return {99 };
546 pid: input.pid,100 }
547 signal: input.signal,101 const current = yield* telemetry.refresh.pipe(Effect.option);
548 signaled: true,102 if (Option.isNone(current)) {
549 message: Option.none(),103 return {
550 };104 pid: input.pid,
551 },105 signal: input.signal,
552 catch: (cause) =>106 signaled: false,
553 new ProcessDiagnosticsSignalFailedError({107 message: Option.some(
554 pid: input.pid,108 `Could not refresh process ${input.pid}; refusing to signal a stale identity.`,
555 signal: input.signal,109 ),
556 cause,110 };
557 }),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,
558 }),149 }),
559 ),150 }).pipe(
560 Effect.catch((error: ProcessDiagnosticsError) =>151 Effect.catch((error) =>
561 Effect.succeed({152 Effect.succeed({
562 pid: input.pid,153 pid: input.pid,
563 signal: input.signal,154 signal: input.signal,
564 signaled: false,155 signaled: false,
565 message: Option.some(error.message),156 message: Option.some(
157 error instanceof Error ? error.message : "Failed to signal process.",
158 ),
566 }),159 }),
567 ),160 ),
568 );161 );
...@@ -572,4 +165,4 @@ export const make = Effect.gen(function* () {...@@ -572,4 +165,4 @@ export const make = Effect.gen(function* () {
572 return ProcessDiagnostics.of({ read, signal });165 return ProcessDiagnostics.of({ read, signal });
573});166});
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 @@...@@ -1,255 +1,170 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import * as DateTime from "effect/DateTime";2import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";5import * 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";
6import * as ProcessResourceMonitor from "./ProcessResourceMonitor.ts";10import * as ProcessResourceMonitor from "./ProcessResourceMonitor.ts";
711
8describe("ProcessResourceMonitor", () => {12describe("ProcessResourceMonitor", () => {
9 it.effect("samples the server root process and descendants", () =>13 it.effect("projects resource telemetry history into the legacy diagnostics contract", () =>
10 Effect.sync(() => {14 Effect.gen(function* () {
11 const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");15 const readAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
12 const samples = ProcessResourceMonitor.collectMonitoredSamples({16 const history: ResourceTelemetryHistoryWithLegacyBuckets = {
13 serverPid: 100,17 readAt,
14 sampledAt,18 windowMs: 60_000,
15 sampledAtMs: DateTime.toEpochMillis(sampledAt),19 bucketMs: 10_000,
16 rows: [20 sampleIntervalMs: 1_000,
17 {21 retainedSampleCount: 2,
18 pid: 100,22 buckets: [
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 },
27 {23 {
28 pid: 101,24 startedAt: DateTime.makeUnsafe("2026-05-05T09:59:50.000Z"),
29 ppid: 100,25 endedAt: readAt,
30 pgid: 100,26 avgCpuPercent: 15,
31 status: "S",27 maxCpuPercent: 25,
32 cpuPercent: 10,28 maxRssBytes: 4_096,
33 rssBytes: 2_000,29 ioReadBytes: 1_024,
34 elapsed: "00:20",30 ioWriteBytes: 2_048,
35 command: "codex app-server",31 maxProcessCount: 2,
36 },32 },
33 ],
34 legacyBackendBuckets: [
37 {35 {
38 pid: 102,36 startedAt: DateTime.makeUnsafe("2026-05-05T09:59:50.000Z"),
39 ppid: 101,37 endedAt: readAt,
40 pgid: 100,38 avgCpuPercent: 5,
41 status: "R",39 maxCpuPercent: 8,
42 cpuPercent: 50,40 maxRssBytes: 4_096,
43 rssBytes: 3_000,41 ioReadBytes: 1_024,
44 elapsed: "00:05",42 ioWriteBytes: 2_048,
45 command: "rg needle",43 maxProcessCount: 1,
46 },44 },
45 ],
46 topProcesses: [
47 {47 {
48 pid: 200,48 identity: { pid: process.pid, startTimeMs: 100 },
49 ppid: 1,49 ppid: 1,
50 pgid: 200,50 depth: 0,
51 status: "R",51 name: "node",
52 cpuPercent: 99,52 command: "t3 server",
53 rssBytes: 9_000,53 category: "server",
54 elapsed: "00:05",54 firstSeenAt: DateTime.makeUnsafe("2026-05-05T09:59:55.000Z"),
55 command: "unrelated",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,
56 },66 },
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: [
192 {67 {
193 pid: 100,68 identity: { pid: 5_000, startTimeMs: 200 },
194 ppid: 1,69 ppid: 1,
195 pgid: 100,70 depth: 0,
196 status: "S",71 name: "electron",
197 cpuPercent: 1,72 command: "electron",
198 rssBytes: 1_000,73 category: "electron-main",
199 elapsed: "01:00",74 firstSeenAt: DateTime.makeUnsafe("2026-05-05T09:59:55.000Z"),
200 command: "t3 server",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,
201 },86 },
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 })),
212 ],87 ],
213 });88 health: {
21489 native: {
215 const result = ProcessResourceMonitor.aggregateProcessResourceHistory({90 status: "degraded",
216 samples,91 lastSampleAt: Option.some(readAt),
217 readAt: sampledAt,92 lastError: Option.some("collector stalled"),
218 readAtMs: DateTime.toEpochMillis(sampledAt),93 },
219 windowMs: 60_000,94 desktop: {
220 bucketMs: 10_000,95 status: "healthy",
221 lastFailure: null,96 lastSampleAt: Option.some(readAt),
222 });97 lastError: Option.none(),
22398 },
224 expect(result.topProcesses).toHaveLength(36);99 sidecarVersion: Option.some("0.1.0"),
225 expect(result.topProcesses.some((process) => process.command === "worker 34")).toBe(true);100 sidecarPid: Option.some(9_000),
226 }),101 restartCount: 1,
227 );102 collectionDurationMicros: 250,
228103 scannedProcessCount: 80,
229 it.effect("exposes bounded failure diagnostics while retaining the exact cause", () =>104 retainedProcessCount: 2,
230 Effect.sync(() => {105 inaccessibleProcessCount: 0,
231 const readAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");106 },
232 const cause = new Error("stderr included credential=secret-value");107 };
233 const failure = new ProcessResourceMonitor.ProcessResourceSamplingError({108 const telemetry: ResourceTelemetry.ResourceTelemetry["Service"] = {
234 failureTag: "ProcessDiagnosticsQueryFailedError",109 latest: Effect.die("unused"),
235 cause,110 changes: Stream.empty,
236 });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({126 const result = yield* Effect.service(ProcessResourceMonitor.ProcessResourceMonitor).pipe(
239 samples: [],127 Effect.flatMap((monitor) =>
240 readAt,128 monitor.readHistory({
241 readAtMs: DateTime.toEpochMillis(readAt),129 windowMs: 60_000,
242 windowMs: 60_000,130 bucketMs: 10_000,
243 bucketMs: 10_000,131 }),
244 lastFailure: failure,132 ),
245 });133 Effect.provide(layer),
134 );
246135
247 expect(failure.cause).toBe(cause);136 expect(result.totalCpuSecondsApprox).toBe(1.5);
248 expect(Option.getOrThrow(result.error)).toEqual({137 expect(result.topProcesses).toEqual([
249 failureTag: "ProcessDiagnosticsQueryFailedError",138 {
250 message: "Failed to sample process resources (ProcessDiagnosticsQueryFailedError).",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,
251 });161 });
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 );
253 }),168 }),
254 );169 );
255});170});
apps/server/src/diagnostics/ProcessResourceMonitor.ts+62-304
...@@ -1,55 +1,14 @@...@@ -1,55 +1,14 @@
1import {1import type {
2 ServerProcessResourceHistoryFailureTag,2 ResourceTelemetryProcessCategory,
3 type ServerProcessResourceHistoryBucket,3 ServerProcessResourceHistoryInput,
4 type ServerProcessResourceHistoryFailureTag as ServerProcessResourceHistoryFailureTagType,4 ServerProcessResourceHistoryResult,
5 type ServerProcessResourceHistoryInput,
6 type ServerProcessResourceHistoryResult,
7 type ServerProcessResourceHistorySummary,
8} from "@t3tools/contracts";5} from "@t3tools/contracts";
9import * as Context from "effect/Context";6import * as Context from "effect/Context";
10import * as DateTime from "effect/DateTime";
11import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";9import * 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";11import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.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}
5312
54export class ProcessResourceMonitor extends Context.Service<13export class ProcessResourceMonitor extends Context.Service<
55 ProcessResourceMonitor,14 ProcessResourceMonitor,
...@@ -60,270 +19,69 @@ export class ProcessResourceMonitor extends Context.Service<...@@ -60,270 +19,69 @@ export class ProcessResourceMonitor extends Context.Service<
60 }19 }
61>()("t3/diagnostics/ProcessResourceMonitor") {}20>()("t3/diagnostics/ProcessResourceMonitor") {}
6221
63function dateTimeFromMillis(ms: number): DateTime.Utc {22function isLegacyBackendCategory(category: ResourceTelemetryProcessCategory): boolean {
64 return DateTime.makeUnsafe(ms);23 return (
65}24 category === "server" ||
6625 category === "server-child" ||
67function sampleKey(row: Pick<ProcessDiagnostics.ProcessRow, "pid" | "command">): string {26 category === "provider-root" ||
68 return `${row.pid}:${row.command}`;27 category === "terminal-root"
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),
86 );28 );
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);
133}29}
13430
135function summarizeProcesses(31export const make = Effect.fn("makeProcessResourceMonitor")(function* () {
136 samples: ReadonlyArray<ProcessResourceSample>,32 const telemetry = yield* ResourceTelemetry.ResourceTelemetry;
137): ReadonlyArray<ServerProcessResourceHistorySummary> {33 const readHistory: ProcessResourceMonitor["Service"]["readHistory"] = (input) =>
138 const groups = new Map<string, ProcessResourceSample[]>();34 telemetry.readHistory(input).pipe(
139 for (const sample of samples) {35 Effect.map((history) => {
140 const processSamples = groups.get(sample.processKey) ?? [];36 const topProcesses = history.topProcesses
141 processSamples.push(sample);37 .filter((entry) => isLegacyBackendCategory(entry.category))
142 groups.set(sample.processKey, processSamples);38 .map((entry) => ({
143 }39 processKey: `${entry.identity.pid}:${entry.identity.startTimeMs}`,
14440 pid: entry.identity.pid,
145 return [...groups.entries()]41 ppid: entry.ppid,
146 .map(([processKey, processSamples]) => {42 command: entry.command || entry.name || "unknown",
147 const sorted = processSamples.toSorted((left, right) => left.sampledAtMs - right.sampledAtMs);43 depth: entry.depth,
148 const first = sorted[0]!;44 isServerRoot: entry.category === "server",
149 const latest = sorted[sorted.length - 1]!;45 firstSeenAt: entry.firstSeenAt,
150 const cpuPercentTotal = sorted.reduce((total, sample) => total + sample.cpuPercent, 0);46 lastSeenAt: entry.lastSeenAt,
151 const maxCpuPercent = Math.max(...sorted.map((sample) => sample.cpuPercent));47 currentCpuPercent: entry.currentCpuPercent,
152 const maxRssBytes = Math.max(...sorted.map((sample) => sample.rssBytes));48 avgCpuPercent: entry.avgCpuPercent,
153 const cpuSecondsApprox = sorted.reduce(49 maxCpuPercent: entry.maxCpuPercent,
154 (total, sample) => total + (sample.cpuPercent / 100) * (SAMPLE_INTERVAL_MS / 1_000),50 cpuSecondsApprox: entry.cpuTimeMs / 1_000,
155 0,51 currentRssBytes: entry.currentRssBytes,
156 );52 maxRssBytes: entry.peakRssBytes,
15753 sampleCount: entry.sampleCount,
158 return {54 }));
159 processKey,55 return {
160 pid: latest.pid,56 readAt: history.readAt,
161 ppid: latest.ppid,57 windowMs: history.windowMs,
162 command: latest.command,58 bucketMs: history.bucketMs,
163 depth: latest.depth,59 sampleIntervalMs: history.sampleIntervalMs,
164 isServerRoot: latest.isServerRoot,60 retainedSampleCount: history.retainedSampleCount,
165 firstSeenAt: first.sampledAt,61 totalCpuSecondsApprox: topProcesses.reduce(
166 lastSeenAt: latest.sampledAt,62 (total, entry) => total + entry.cpuSecondsApprox,
167 currentCpuPercent: latest.cpuPercent,63 0,
168 avgCpuPercent: cpuPercentTotal / sorted.length,64 ),
169 maxCpuPercent,65 buckets: (history.legacyBackendBuckets ?? history.buckets).map((bucket) => ({
170 cpuSecondsApprox,66 startedAt: bucket.startedAt,
171 currentRssBytes: latest.rssBytes,67 endedAt: bucket.endedAt,
172 maxRssBytes,68 avgCpuPercent: bucket.avgCpuPercent,
173 sampleCount: sorted.length,69 maxCpuPercent: bucket.maxCpuPercent,
174 } satisfies ServerProcessResourceHistorySummary;70 maxRssBytes: bucket.maxRssBytes,
175 })71 maxProcessCount: bucket.maxProcessCount,
176 .toSorted((left, right) => right.cpuSecondsApprox - left.cpuSecondsApprox);72 })),
177}73 topProcesses,
17874 error: history.health.native.lastError.pipe(
179function buildBuckets(input: {75 Option.map((message) => ({
180 readonly samples: ReadonlyArray<ProcessResourceSample>;76 failureTag: "ProcessDiagnosticsQueryFailedError" as const,
181 readonly nowMs: number;77 message,
182 readonly windowMs: number;78 })),
183 readonly bucketMs: number;79 ),
184}): ReadonlyArray<ServerProcessResourceHistoryBucket> {80 };
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,
278 }),81 }),
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),
286 );82 );
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
326 return ProcessResourceMonitor.of({ readHistory });84 return ProcessResourceMonitor.of({ readHistory });
327});85});
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...@@ -10,6 +10,7 @@ import * as OtlpSerialization from "effect/unstable/observability/OtlpSerializat
10import * as OtlpTracer from "effect/unstable/observability/OtlpTracer";10import * as OtlpTracer from "effect/unstable/observability/OtlpTracer";
1111
12import * as ServerConfig from "../../config.ts";12import * as ServerConfig from "../../config.ts";
13import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts";
13import { ServerLoggerLive } from "../../serverLogger.ts";14import { ServerLoggerLive } from "../../serverLogger.ts";
14import * as BrowserTraceCollector from "../BrowserTraceCollector.ts";15import * as BrowserTraceCollector from "../BrowserTraceCollector.ts";
1516
...@@ -18,6 +19,7 @@ const otlpSerializationLayer = OtlpSerialization.layerJson;...@@ -18,6 +19,7 @@ const otlpSerializationLayer = OtlpSerialization.layerJson;
18export const ObservabilityLive = Layer.unwrap(19export const ObservabilityLive = Layer.unwrap(
19 Effect.gen(function* () {20 Effect.gen(function* () {
20 const config = yield* ServerConfig.ServerConfig;21 const config = yield* ServerConfig.ServerConfig;
22 const attribution = yield* ResourceAttribution.ResourceAttribution;
2123
22 const traceReferencesLayer = Layer.mergeAll(24 const traceReferencesLayer = Layer.mergeAll(
23 Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel),25 Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel),
...@@ -32,6 +34,14 @@ export const ObservabilityLive = Layer.unwrap(...@@ -32,6 +34,14 @@ export const ObservabilityLive = Layer.unwrap(
32 maxBytes: config.traceMaxBytes,34 maxBytes: config.traceMaxBytes,
33 maxFiles: config.traceMaxFiles,35 maxFiles: config.traceMaxFiles,
34 batchWindowMs: config.traceBatchWindowMs,36 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 }),
35 });45 });
36 const delegate =46 const delegate =
37 config.otlpTracesUrl === undefined47 config.otlpTracesUrl === undefined
apps/server/src/provider/Drivers/ClaudeDriver.ts+2-2
...@@ -24,6 +24,7 @@ import { HttpClient } from "effect/unstable/http";...@@ -24,6 +24,7 @@ import { HttpClient } from "effect/unstable/http";
24import { ChildProcessSpawner } from "effect/unstable/process";24import { ChildProcessSpawner } from "effect/unstable/process";
2525
26import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts";26import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts";
27import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
27import { ServerConfig } from "../../config.ts";28import { ServerConfig } from "../../config.ts";
28import { ServerSettingsService } from "../../serverSettings.ts";29import { ServerSettingsService } from "../../serverSettings.ts";
29import { ProviderDriverError } from "../Errors.ts";30import { ProviderDriverError } from "../Errors.ts";
...@@ -57,7 +58,6 @@ import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "...@@ -57,7 +58,6 @@ import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "
57const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);58const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
5859
59const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");60const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
60const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
61const CAPABILITIES_PROBE_TTL = Duration.minutes(5);61const CAPABILITIES_PROBE_TTL = Duration.minutes(5);
6262
63function isClaudeNativeCommandPath(commandPath: string): boolean {63function isClaudeNativeCommandPath(commandPath: string): boolean {
...@@ -82,6 +82,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({...@@ -82,6 +82,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({
82});82});
8383
84export type ClaudeDriverEnv =84export type ClaudeDriverEnv =
85 | BackgroundPolicy.BackgroundPolicy
85 | ChildProcessSpawner.ChildProcessSpawner86 | ChildProcessSpawner.ChildProcessSpawner
86 | Crypto.Crypto87 | Crypto.Crypto
87 | FileSystem.FileSystem88 | FileSystem.FileSystem
...@@ -190,7 +191,6 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {...@@ -190,7 +191,6 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
190 Effect.provideService(HttpClient.HttpClient, httpClient),191 Effect.provideService(HttpClient.HttpClient, httpClient),
191 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),192 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
192 ),193 ),
193 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
194 }).pipe(194 }).pipe(
195 Effect.mapError(195 Effect.mapError(
196 (cause) =>196 (cause) =>
apps/server/src/provider/Drivers/CodexDriver.ts+2-3
...@@ -22,7 +22,6 @@...@@ -22,7 +22,6 @@
22 * @module provider/Drivers/CodexDriver22 * @module provider/Drivers/CodexDriver
23 */23 */
24import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";24import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
25import * as Duration from "effect/Duration";
26import * as Crypto from "effect/Crypto";25import * as Crypto from "effect/Crypto";
27import * as Effect from "effect/Effect";26import * as Effect from "effect/Effect";
28import * as FileSystem from "effect/FileSystem";27import * as FileSystem from "effect/FileSystem";
...@@ -32,6 +31,7 @@ import { HttpClient } from "effect/unstable/http";...@@ -32,6 +31,7 @@ import { HttpClient } from "effect/unstable/http";
32import { ChildProcessSpawner } from "effect/unstable/process";31import { ChildProcessSpawner } from "effect/unstable/process";
3332
34import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts";33import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts";
34import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
35import { ServerConfig } from "../../config.ts";35import { ServerConfig } from "../../config.ts";
36import { ServerSettingsService } from "../../serverSettings.ts";36import { ServerSettingsService } from "../../serverSettings.ts";
37import { ProviderDriverError } from "../Errors.ts";37import { ProviderDriverError } from "../Errors.ts";
...@@ -60,7 +60,6 @@ import {...@@ -60,7 +60,6 @@ import {
60const decodeCodexSettings = Schema.decodeSync(CodexSettings);60const decodeCodexSettings = Schema.decodeSync(CodexSettings);
6161
62const DRIVER_KIND = ProviderDriverKind.make("codex");62const DRIVER_KIND = ProviderDriverKind.make("codex");
63const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
64const UPDATE = makePackageManagedProviderMaintenanceResolver({63const UPDATE = makePackageManagedProviderMaintenanceResolver({
65 provider: DRIVER_KIND,64 provider: DRIVER_KIND,
66 npmPackageName: "@openai/codex",65 npmPackageName: "@openai/codex",
...@@ -74,6 +73,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({...@@ -74,6 +73,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({
74 * registered driver and the runtime satisfies them once.73 * registered driver and the runtime satisfies them once.
75 */74 */
76export type CodexDriverEnv =75export type CodexDriverEnv =
76 | BackgroundPolicy.BackgroundPolicy
77 | ChildProcessSpawner.ChildProcessSpawner77 | ChildProcessSpawner.ChildProcessSpawner
78 | Crypto.Crypto78 | Crypto.Crypto
79 | FileSystem.FileSystem79 | FileSystem.FileSystem
...@@ -186,7 +186,6 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {...@@ -186,7 +186,6 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
186 Effect.provideService(HttpClient.HttpClient, httpClient),186 Effect.provideService(HttpClient.HttpClient, httpClient),
187 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),187 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
188 ),188 ),
189 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
190 }).pipe(189 }).pipe(
191 Effect.mapError(190 Effect.mapError(
192 (cause) =>191 (cause) =>
apps/server/src/provider/Drivers/CursorDriver.ts+3-4
...@@ -12,7 +12,6 @@...@@ -12,7 +12,6 @@
12 * @module provider/Drivers/CursorDriver12 * @module provider/Drivers/CursorDriver
13 */13 */
14import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";14import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
15import * as Duration from "effect/Duration";
16import * as Crypto from "effect/Crypto";15import * as Crypto from "effect/Crypto";
17import * as Effect from "effect/Effect";16import * as Effect from "effect/Effect";
18import * as FileSystem from "effect/FileSystem";17import * as FileSystem from "effect/FileSystem";
...@@ -21,6 +20,7 @@ import * as Schema from "effect/Schema";...@@ -21,6 +20,7 @@ import * as Schema from "effect/Schema";
21import { HttpClient } from "effect/unstable/http";20import { HttpClient } from "effect/unstable/http";
22import { ChildProcessSpawner } from "effect/unstable/process";21import { ChildProcessSpawner } from "effect/unstable/process";
2322
23import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
24import { ServerConfig } from "../../config.ts";24import { ServerConfig } from "../../config.ts";
25import { ServerSettingsService } from "../../serverSettings.ts";25import { ServerSettingsService } from "../../serverSettings.ts";
26import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts";26import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts";
...@@ -53,7 +53,6 @@ import {...@@ -53,7 +53,6 @@ import {
53const decodeCursorSettings = Schema.decodeSync(CursorSettings);53const decodeCursorSettings = Schema.decodeSync(CursorSettings);
5454
55const DRIVER_KIND = ProviderDriverKind.make("cursor");55const DRIVER_KIND = ProviderDriverKind.make("cursor");
56const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
57const UPDATE: ProviderMaintenanceCapabilitiesResolver = {56const UPDATE: ProviderMaintenanceCapabilitiesResolver = {
58 resolve: (options) =>57 resolve: (options) =>
59 makeProviderMaintenanceCapabilities({58 makeProviderMaintenanceCapabilities({
...@@ -66,6 +65,7 @@ const UPDATE: ProviderMaintenanceCapabilitiesResolver = {...@@ -66,6 +65,7 @@ const UPDATE: ProviderMaintenanceCapabilitiesResolver = {
66};65};
6766
68export type CursorDriverEnv =67export type CursorDriverEnv =
68 | BackgroundPolicy.BackgroundPolicy
69 | ChildProcessSpawner.ChildProcessSpawner69 | ChildProcessSpawner.ChildProcessSpawner
70 | Crypto.Crypto70 | Crypto.Crypto
71 | FileSystem.FileSystem71 | FileSystem.FileSystem
...@@ -160,8 +160,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {...@@ -160,8 +160,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
160 publishSnapshot,160 publishSnapshot,
161 stampIdentity,161 stampIdentity,
162 httpClient,162 httpClient,
163 }),163 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)),
164 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
165 }).pipe(164 }).pipe(
166 Effect.mapError(165 Effect.mapError(
167 (cause) =>166 (cause) =>
apps/server/src/provider/Drivers/GrokDriver.ts+2-3
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1import { GrokSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";1import { GrokSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
2import * as Duration from "effect/Duration";
3import * as Crypto from "effect/Crypto";2import * as Crypto from "effect/Crypto";
4import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";4import * as FileSystem from "effect/FileSystem";
...@@ -8,6 +7,7 @@ import * as Schema from "effect/Schema";...@@ -8,6 +7,7 @@ import * as Schema from "effect/Schema";
8import { HttpClient } from "effect/unstable/http";7import { HttpClient } from "effect/unstable/http";
9import { ChildProcessSpawner } from "effect/unstable/process";8import { ChildProcessSpawner } from "effect/unstable/process";
109
10import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
11import { ServerConfig } from "../../config.ts";11import { ServerConfig } from "../../config.ts";
12import { ServerSettingsService } from "../../serverSettings.ts";12import { ServerSettingsService } from "../../serverSettings.ts";
13import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts";13import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts";
...@@ -40,7 +40,6 @@ import {...@@ -40,7 +40,6 @@ import {
40const decodeGrokSettings = Schema.decodeSync(GrokSettings);40const decodeGrokSettings = Schema.decodeSync(GrokSettings);
4141
42const DRIVER_KIND = ProviderDriverKind.make("grok");42const DRIVER_KIND = ProviderDriverKind.make("grok");
43const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
44const UPDATE = makeStaticProviderMaintenanceResolver(43const UPDATE = makeStaticProviderMaintenanceResolver(
45 makeManualOnlyProviderMaintenanceCapabilities({44 makeManualOnlyProviderMaintenanceCapabilities({
46 provider: DRIVER_KIND,45 provider: DRIVER_KIND,
...@@ -49,6 +48,7 @@ const UPDATE = makeStaticProviderMaintenanceResolver(...@@ -49,6 +48,7 @@ const UPDATE = makeStaticProviderMaintenanceResolver(
49);48);
5049
51export type GrokDriverEnv =50export type GrokDriverEnv =
51 | BackgroundPolicy.BackgroundPolicy
52 | ChildProcessSpawner.ChildProcessSpawner52 | ChildProcessSpawner.ChildProcessSpawner
53 | Crypto.Crypto53 | Crypto.Crypto
54 | FileSystem.FileSystem54 | FileSystem.FileSystem
...@@ -136,7 +136,6 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {...@@ -136,7 +136,6 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
136 publishSnapshot,136 publishSnapshot,
137 httpClient,137 httpClient,
138 }),138 }),
139 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
140 }).pipe(139 }).pipe(
141 Effect.mapError(140 Effect.mapError(
142 (cause) =>141 (cause) =>
apps/server/src/provider/Drivers/OpenCodeDriver.ts+2-3
...@@ -13,7 +13,6 @@...@@ -13,7 +13,6 @@
13 * @module provider/Drivers/OpenCodeDriver13 * @module provider/Drivers/OpenCodeDriver
14 */14 */
15import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";15import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
16import * as Duration from "effect/Duration";
17import * as Crypto from "effect/Crypto";16import * as Crypto from "effect/Crypto";
18import * as Effect from "effect/Effect";17import * as Effect from "effect/Effect";
19import * as FileSystem from "effect/FileSystem";18import * as FileSystem from "effect/FileSystem";
...@@ -23,6 +22,7 @@ import { HttpClient } from "effect/unstable/http";...@@ -23,6 +22,7 @@ import { HttpClient } from "effect/unstable/http";
23import { ChildProcessSpawner } from "effect/unstable/process";22import { ChildProcessSpawner } from "effect/unstable/process";
2423
25import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts";24import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts";
25import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
26import { ServerConfig } from "../../config.ts";26import { ServerConfig } from "../../config.ts";
27import { ServerSettingsService } from "../../serverSettings.ts";27import { ServerSettingsService } from "../../serverSettings.ts";
28import { ProviderDriverError } from "../Errors.ts";28import { ProviderDriverError } from "../Errors.ts";
...@@ -55,7 +55,6 @@ import {...@@ -55,7 +55,6 @@ import {
55const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings);55const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings);
5656
57const DRIVER_KIND = ProviderDriverKind.make("opencode");57const DRIVER_KIND = ProviderDriverKind.make("opencode");
58const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);
5958
60function isOpenCodeNativeCommandPath(commandPath: string): boolean {59function isOpenCodeNativeCommandPath(commandPath: string): boolean {
61 const normalized = normalizeCommandPath(commandPath);60 const normalized = normalizeCommandPath(commandPath);
...@@ -78,6 +77,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({...@@ -78,6 +77,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({
78});77});
7978
80export type OpenCodeDriverEnv =79export type OpenCodeDriverEnv =
80 | BackgroundPolicy.BackgroundPolicy
81 | ChildProcessSpawner.ChildProcessSpawner81 | ChildProcessSpawner.ChildProcessSpawner
82 | Crypto.Crypto82 | Crypto.Crypto
83 | FileSystem.FileSystem83 | FileSystem.FileSystem
...@@ -166,7 +166,6 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>...@@ -166,7 +166,6 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
166 Effect.provideService(HttpClient.HttpClient, httpClient),166 Effect.provideService(HttpClient.HttpClient, httpClient),
167 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),167 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
168 ),168 ),
169 refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
170 },169 },
171 ).pipe(170 ).pipe(
172 Effect.mapError(171 Effect.mapError(
apps/server/src/provider/Layers/ClaudeAdapter.ts+4-1
...@@ -1354,6 +1354,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -1354,6 +1354,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
1354 stream: "native",1354 stream: "native",
1355 })1355 })
1356 : undefined);1356 : undefined);
1357 const managedNativeEventLogger =
1358 options?.nativeEventLogger === undefined ? nativeEventLogger : undefined;
13571359
1358 const createQuery =1360 const createQuery =
1359 options?.createQuery ??1361 options?.createQuery ??
...@@ -1387,7 +1389,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -1387,7 +1389,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
1387 const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>1389 const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
1388 Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid);1390 Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid);
13891391
1390 const logNativeSdkMessage = Effect.fn("logNativeSdkMessage")(function* (1392 const logNativeSdkMessage = Effect.fnUntraced(function* (
1391 context: ClaudeSessionContext,1393 context: ClaudeSessionContext,
1392 message: SDKMessage,1394 message: SDKMessage,
1393 ) {1395 ) {
...@@ -3922,6 +3924,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3922,6 +3924,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3922 Effect.logError("Failed to emit Claude session shutdown event.", { cause }),3924 Effect.logError("Failed to emit Claude session shutdown event.", { cause }),
3923 ),3925 ),
3924 Effect.tap(() => Queue.shutdown(runtimeEventQueue)),3926 Effect.tap(() => Queue.shutdown(runtimeEventQueue)),
3927 Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void),
3925 ),3928 ),
3926 );3929 );
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", () =>...@@ -1291,7 +1291,7 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () =>
1291 yield* Scope.close(scope, Exit.void);1291 yield* Scope.close(scope, Exit.void);
1292 scopeClosed = true;1292 scopeClosed = true;
12931293
1294 const threadLogPath = NodePath.join(tempDir, "thread-logger.log");1294 const threadLogPath = NodePath.join(tempDir, "provider-native.thread-logger.log");
1295 NodeAssert.equal(NodeFS.existsSync(threadLogPath), true);1295 NodeAssert.equal(NodeFS.existsSync(threadLogPath), true);
1296 const contents = NodeFS.readFileSync(threadLogPath, "utf8");1296 const contents = NodeFS.readFileSync(threadLogPath, "utf8");
1297 NodeAssert.match(contents, /NTIVE: .*"message":"native flush test"/);1297 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* (...@@ -1646,7 +1646,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
1646 ),1646 ),
1647 );1647 );
16481648
1649 const writeNativeEvent = Effect.fn("writeNativeEvent")(function* (event: ProviderEvent) {1649 const writeNativeEvent = Effect.fnUntraced(function* (event: ProviderEvent) {
1650 if (!nativeEventLogger) {1650 if (!nativeEventLogger) {
1651 return;1651 return;
1652 }1652 }
apps/server/src/provider/Layers/EventNdjsonLogger.test.ts+361-15
...@@ -5,14 +5,30 @@ import * as NodePath from "node:path";...@@ -5,14 +5,30 @@ import * as NodePath from "node:path";
55
6import { ThreadId } from "@t3tools/contracts";6import { ThreadId } from "@t3tools/contracts";
7import { assert, describe, it } from "@effect/vitest";7import { assert, describe, it } from "@effect/vitest";
8import * as Clock from "effect/Clock";
8import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
10import * as Fiber from "effect/Fiber";
9import * as Logger from "effect/Logger";11import * as Logger from "effect/Logger";
10import * as Schema from "effect/Schema";12import * 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
14const encodeUnknownJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString);23const 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
16function parseLogLine(line: string) {32function parseLogLine(line: string) {
17 const match = /^\[([^\]]+)\] ([A-Z]+): (.+)$/.exec(line);33 const match = /^\[([^\]]+)\] ([A-Z]+): (.+)$/.exec(line);
18 assert.notEqual(match, null);34 assert.notEqual(match, null);
...@@ -87,8 +103,8 @@ describe("EventNdjsonLogger", () => {...@@ -87,8 +103,8 @@ describe("EventNdjsonLogger", () => {
87 );103 );
88 yield* logger.close();104 yield* logger.close();
89105
90 const threadOnePath = NodePath.join(tempDir, "thread-1.log");106 const threadOnePath = ownedLogPath(basePath, "thread-1");
91 const threadTwoPath = NodePath.join(tempDir, "thread-2.log");107 const threadTwoPath = ownedLogPath(basePath, "thread-2");
92 assert.equal(NodeFS.existsSync(threadOnePath), true);108 assert.equal(NodeFS.existsSync(threadOnePath), true);
93 assert.equal(NodeFS.existsSync(threadTwoPath), true);109 assert.equal(NodeFS.existsSync(threadTwoPath), true);
94110
...@@ -129,7 +145,7 @@ describe("EventNdjsonLogger", () => {...@@ -129,7 +145,7 @@ describe("EventNdjsonLogger", () => {
129 yield* logger.write({ id: "evt-invalid-thread" }, "!!!" as unknown as ThreadId);145 yield* logger.write({ id: "evt-invalid-thread" }, "!!!" as unknown as ThreadId);
130 yield* logger.close();146 yield* logger.close();
131147
132 const globalPath = NodePath.join(tempDir, "_global.log");148 const globalPath = ownedLogPath(basePath, "_global");
133 assert.equal(NodeFS.existsSync(globalPath), true);149 assert.equal(NodeFS.existsSync(globalPath), true);
134 const lines = NodeFS.readFileSync(globalPath, "utf8")150 const lines = NodeFS.readFileSync(globalPath, "utf8")
135 .trim()151 .trim()
...@@ -138,9 +154,9 @@ describe("EventNdjsonLogger", () => {...@@ -138,9 +154,9 @@ describe("EventNdjsonLogger", () => {
138 assert.equal(lines.length, 2);154 assert.equal(lines.length, 2);
139 assert.equal(Number.isNaN(Date.parse(lines[0]?.observedAt ?? "")), false);155 assert.equal(Number.isNaN(Date.parse(lines[0]?.observedAt ?? "")), false);
140 assert.equal(Number.isNaN(Date.parse(lines[1]?.observedAt ?? "")), false);156 assert.equal(Number.isNaN(Date.parse(lines[1]?.observedAt ?? "")), false);
141 assert.equal(lines[0]?.stream, "CANON");157 assert.equal(lines[0]?.stream, "ORCH");
142 assert.equal(lines[0]?.payload, '{"id":"evt-no-thread"}');158 assert.equal(lines[0]?.payload, '{"id":"evt-no-thread"}');
143 assert.equal(lines[1]?.stream, "CANON");159 assert.equal(lines[1]?.stream, "ORCH");
144 assert.equal(lines[1]?.payload, '{"id":"evt-invalid-thread"}');160 assert.equal(lines[1]?.payload, '{"id":"evt-invalid-thread"}');
145 } finally {161 } finally {
146 NodeFS.rmSync(tempDir, { recursive: true, force: true });162 NodeFS.rmSync(tempDir, { recursive: true, force: true });
...@@ -148,6 +164,197 @@ describe("EventNdjsonLogger", () => {...@@ -148,6 +164,197 @@ describe("EventNdjsonLogger", () => {
148 }),164 }),
149 );165 );
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
151 it.effect("serializes concurrent first writes for the same segment", () =>358 it.effect("serializes concurrent first writes for the same segment", () =>
152 Effect.gen(function* () {359 Effect.gen(function* () {
153 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));360 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-"));
...@@ -172,7 +379,7 @@ describe("EventNdjsonLogger", () => {...@@ -172,7 +379,7 @@ describe("EventNdjsonLogger", () => {
172 );379 );
173 yield* logger.close();380 yield* logger.close();
174381
175 const globalPath = NodePath.join(tempDir, "_global.log");382 const globalPath = ownedLogPath(basePath, "_global");
176 assert.equal(NodeFS.existsSync(globalPath), true);383 assert.equal(NodeFS.existsSync(globalPath), true);
177 const lines = NodeFS.readFileSync(globalPath, "utf8")384 const lines = NodeFS.readFileSync(globalPath, "utf8")
178 .trim()385 .trim()
...@@ -196,19 +403,19 @@ describe("EventNdjsonLogger", () => {...@@ -196,19 +403,19 @@ describe("EventNdjsonLogger", () => {
196 const basePath = NodePath.join(tempDir, "provider-native.ndjson");403 const basePath = NodePath.join(tempDir, "provider-native.ndjson");
197404
198 try {405 try {
199 const logger = yield* makeEventNdjsonLogger(basePath, {406 const store = yield* makeEventNdjsonLogStore(basePath, {
200 stream: "native",
201 maxBytes: 120,407 maxBytes: 120,
202 maxFiles: 2,408 maxFiles: 2,
409 batchWindowMs: 0,
203 });410 });
204 assert.notEqual(logger, undefined);411 const native = store.logger("native");
205 if (!logger) {412 const canonical = store.logger("canonical");
206 return;
207 }
208413
209 for (let index = 0; index < 10; index += 1) {414 for (let index = 0; index < 10; index += 1) {
415 const logger = index % 2 === 0 ? native : canonical;
210 yield* logger.write(416 yield* logger.write(
211 {417 {
418 type: "session.started",
212 threadId: "provider-thread-rotate",419 threadId: "provider-thread-rotate",
213 id: `evt-${index}`,420 id: `evt-${index}`,
214 payload: "x".repeat(40),421 payload: "x".repeat(40),
...@@ -216,9 +423,9 @@ describe("EventNdjsonLogger", () => {...@@ -216,9 +423,9 @@ describe("EventNdjsonLogger", () => {
216 ThreadId.make("thread-rotate"),423 ThreadId.make("thread-rotate"),
217 );424 );
218 }425 }
219 yield* logger.close();426 yield* store.close();
220427
221 const fileStem = "thread-rotate.log";428 const fileStem = NodePath.basename(ownedLogPath(basePath, "thread-rotate"));
222 const matchingFiles = NodeFS.readdirSync(tempDir)429 const matchingFiles = NodeFS.readdirSync(tempDir)
223 .filter((entry) => entry === fileStem || entry.startsWith(`${fileStem}.`))430 .filter((entry) => entry === fileStem || entry.startsWith(`${fileStem}.`))
224 .toSorted();431 .toSorted();
...@@ -240,4 +447,143 @@ describe("EventNdjsonLogger", () => {...@@ -240,4 +447,143 @@ describe("EventNdjsonLogger", () => {
240 }447 }
241 }),448 }),
242 );449 );
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 );
243});589});
apps/server/src/provider/Layers/EventNdjsonLogger.ts+527-197
...@@ -1,10 +1,9 @@...@@ -1,10 +1,9 @@
1// @effect-diagnostics nodeBuiltinImport:off1// @effect-diagnostics nodeBuiltinImport:off
2/**2/**
3 * Provider event logger helper.3 * Best-effort provider event logging with one shared writer per thread.
4 *4 *
5 * Best-effort writer for observability logs. Each record is formatted as a5 * Native and canonical views share batching, rotation, and retention state so
6 * single effect-style text line in a thread-scoped file. Failures are6 * they cannot race while appending to the same thread-scoped file.
7 * downgraded to warnings so provider runtime behavior is unaffected.
8 */7 */
9import * as NodeFS from "node:fs";8import * as NodeFS from "node:fs";
10import * as NodePath from "node:path";9import * as NodePath from "node:path";
...@@ -12,45 +11,147 @@ import * as NodePath from "node:path";...@@ -12,45 +11,147 @@ import * as NodePath from "node:path";
12import type { ThreadId } from "@t3tools/contracts";11import type { ThreadId } from "@t3tools/contracts";
13import { RotatingFileSink } from "@t3tools/shared/logging";12import { RotatingFileSink } from "@t3tools/shared/logging";
14import { errorTag } from "@t3tools/shared/observability";13import { errorTag } from "@t3tools/shared/observability";
14import * as Clock from "effect/Clock";
15import * as DateTime from "effect/DateTime";
15import * as Effect from "effect/Effect";16import * as Effect from "effect/Effect";
16import * as Exit from "effect/Exit";17import * as Exit from "effect/Exit";
17import * as Logger from "effect/Logger";
18import * as Schema from "effect/Schema";18import * as Schema from "effect/Schema";
19import * as Scope from "effect/Scope";19import * as Scope from "effect/Scope";
20import * as SynchronizedRef from "effect/SynchronizedRef";20import * as SynchronizedRef from "effect/SynchronizedRef";
2121
22import { toSafeThreadAttachmentSegment } from "../../attachmentStore.ts";22import { 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;
25const DEFAULT_MAX_FILES = 10;28const 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;
27const GLOBAL_THREAD_SEGMENT = "_global";35const GLOBAL_THREAD_SEGMENT = "_global";
28const LOG_SCOPE = "provider-observability";36const LOG_SCOPE = "provider-observability";
29const encodeUnknownJsonString = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString);37const 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
31export type EventNdjsonStream = "native" | "canonical" | "orchestration";49export type EventNdjsonStream = "native" | "canonical" | "orchestration";
3250
33export interface EventNdjsonLogger {51export interface EventNdjsonLogger {
34 readonly filePath: string;52 readonly filePath: string;
35 write: (event: unknown, threadId: ThreadId | null) => Effect.Effect<void, never, never>;53 readonly write: (event: unknown, threadId: ThreadId | null) => Effect.Effect<void>;
36 close: () => Effect.Effect<void, never, never>;54 readonly close: () => Effect.Effect<void>;
37}55}
3856
39export interface EventNdjsonLoggerOptions {57export interface EventNdjsonLogStore {
40 readonly stream: EventNdjsonStream;58 readonly filePath: string;
59 readonly logger: (stream: EventNdjsonStream) => EventNdjsonLogger;
60 readonly close: () => Effect.Effect<void>;
61}
62
63export interface EventNdjsonLogStoreOptions {
41 readonly maxBytes?: number;64 readonly maxBytes?: number;
42 readonly maxFiles?: number;65 readonly maxFiles?: number;
43 readonly batchWindowMs?: number;66 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;
44}119}
45120
46interface ThreadWriter {121export interface PendingRecord {
47 writeMessage: (message: string) => Effect.Effect<void>;122 readonly stream: EventNdjsonStream;
48 close: () => Effect.Effect<void>;123 readonly threadSegment: string;
124 readonly line: string;
125 readonly bytes: number;
49}126}
50127
51interface LoggerState {128interface StoreState {
52 readonly threadWriters: Map<string, ThreadWriter>;129 readonly pending: ReadonlyArray<PendingRecord>;
53 readonly failedSegments: Set<string>;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>;
54}155}
55156
56function logWarning(message: string, context: Record<string, unknown>): Effect.Effect<void> {157function logWarning(message: string, context: Record<string, unknown>): Effect.Effect<void> {
...@@ -62,225 +163,454 @@ function resolveThreadSegment(raw: string | null | undefined): string {...@@ -62,225 +163,454 @@ function resolveThreadSegment(raw: string | null | undefined): string {
62 return normalized ?? GLOBAL_THREAD_SEGMENT;163 return normalized ?? GLOBAL_THREAD_SEGMENT;
63}164}
64165
65function formatLoggerMessage(message: unknown): string {166function resolveStreamLabel(stream: EventNdjsonStream): string {
66 if (Array.isArray(message)) {167 return stream === "native" ? "NTIVE" : stream === "orchestration" ? "ORCH" : "CANON";
67 return message.map((part) => (typeof part === "string" ? part : String(part))).join(" ");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;
68 }189 }
69 return typeof message === "string" ? message : String(message);
70}190}
71191
72function makeLineLogger(streamLabel: string): Logger.Logger<unknown, string> {192export function writeBatchedMessages(
73 return Logger.make(193 sink: Pick<RotatingFileSink, "write">,
74 ({ date, message }) =>194 records: ReadonlyArray<PendingRecord>,
75 `[${date.toISOString()}] ${streamLabel}: ${formatLoggerMessage(message)}\n`,195 maxBytes: number,
76 );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();
77}221}
78222
79function resolveStreamLabel(stream: EventNdjsonStream): string {223function isProviderLogFile(filePath: string, fileName: string, filePrefix: string): boolean {
80 switch (stream) {224 if (!/\.log(?:\.\d+)?$/u.test(fileName)) return false;
81 case "native":225 if (fileName.startsWith(filePrefix)) return true;
82 return "NTIVE";226
83 case "canonical":227 const descriptor = NodeFS.openSync(filePath, "r");
84 case "orchestration":228 try {
85 default:229 const header = Buffer.alloc(256);
86 return "CANON";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);
87 }234 }
88}235}
89236
90const toLogMessage = Effect.fn("toLogMessage")(function* (237function enforceRetention(input: {
91 event: unknown,238 readonly directory: string;
92): Effect.fn.Return<string | undefined> {239 readonly maxTotalBytes: number;
93 return yield* encodeUnknownJsonString(event).pipe(240 readonly maxAgeMs: number;
94 Effect.catch((error) =>241 readonly activeFilePaths: ReadonlySet<string>;
95 logWarning("failed to serialize provider event log record", {242 readonly filePrefix: string;
96 errorTag: errorTag(error),243 readonly now: number;
97 }).pipe(Effect.as(undefined)),244}): RetentionResult {
98 ),245 const failures: Array<FileOperationFailure> = [];
99 );246 const files: Array<{ filePath: string; mtimeMs: number; size: number }> = [];
100});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: {255 for (const entry of entries) {
103 readonly filePath: string;256 if (!entry.isFile()) continue;
104 readonly maxBytes: number;257 const filePath = NodePath.join(input.directory, entry.name);
105 readonly maxFiles: number;
106 readonly batchWindowMs: number;
107 readonly streamLabel: string;
108}): Effect.fn.Return<ThreadWriter | undefined> {
109 const sinkResult = yield* Effect.sync(() => {
110 try {258 try {
111 return {259 if (!isProviderLogFile(filePath, entry.name, input.filePrefix)) continue;
112 ok: true as const,260 const stat = NodeFS.statSync(filePath);
113 sink: new RotatingFileSink({261 files.push({ filePath, mtimeMs: stat.mtimeMs, size: stat.size });
114 filePath: input.filePath,262 } catch (cause) {
115 maxBytes: input.maxBytes,263 failures.push({ filePath, cause });
116 maxFiles: input.maxFiles,
117 throwOnError: true,
118 }),
119 };
120 } catch (error) {
121 return { ok: false as const, error };
122 }264 }
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);
123 });283 });
124284
125 if (!sinkResult.ok) {285 for (const file of retained.toSorted(
126 yield* logWarning("failed to initialize provider thread log file", {286 (left, right) => left.mtimeMs - right.mtimeMs || left.filePath.localeCompare(right.filePath),
127 filePath: input.filePath,287 )) {
128 errorTag: errorTag(sinkResult.error),288 if (totalBytes <= input.maxTotalBytes) break;
129 });289 remove(file);
130 return undefined;
131 }290 }
132291
133 const sink = sinkResult.sink;292 return { failures };
134 const scope = yield* Scope.make();293}
135 const lineLogger = makeLineLogger(input.streamLabel);294
136 const batchedLogger = yield* Logger.batched(lineLogger, {295function validateOption(input: {
137 window: input.batchWindowMs,296 readonly filePath: string;
138 flush: Effect.fn("makeThreadWriter.flush")(function* (messages) {297 readonly option: string;
139 const flushResult = yield* Effect.sync(() => {298 readonly value: number;
140 try {299 readonly minimum: number;
141 for (const message of messages) {300}): EventNdjsonLogConfigurationError | undefined {
142 sink.write(message);301 if (Number.isInteger(input.value) && input.value >= input.minimum) return undefined;
143 }302 return new EventNdjsonLogConfigurationError(input);
144 return { ok: true as const };303}
145 } catch (error) {304
146 return { ok: false as const, error };305function resolveOptions(
147 }306 filePath: string,
148 });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) {340function drainPending(input: {
151 yield* logWarning("provider event log batch flush failed", {341 readonly directory: string;
152 filePath: input.filePath,342 readonly options: ResolvedOptions;
153 errorTag: errorTag(flushResult.error),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,
154 });377 });
378 sinks.set(threadSegment, sink);
379 } catch (cause) {
380 failures.push({ filePath, cause });
381 continue;
155 }382 }
156 }),383 }
157 }).pipe(Effect.provideService(Scope.Scope, scope));
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 {404 const retentionDue =
162 writeMessage(message: string) {405 input.now - input.state.lastRetentionAt >= input.options.retentionCheckIntervalMs;
163 return Effect.log(message).pipe(Effect.provide(loggerLayer));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],
164 },428 },
165 close() {429 {
166 return Scope.close(scope, Exit.void);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,
167 },436 },
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 );
169});448});
170449
171export const makeEventNdjsonLogger = Effect.fn("makeEventNdjsonLogger")(function* (450export const makeEventNdjsonLogStore = Effect.fnUntraced(function* (
172 filePath: string,451 filePath: string,
173 options: EventNdjsonLoggerOptions,452 options: EventNdjsonLogStoreOptions = {},
174): Effect.fn.Return<EventNdjsonLogger | undefined> {453): Effect.fn.Return<EventNdjsonLogStore, EventNdjsonLogStoreError> {
175 const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;454 const resolved = yield* resolveOptions(filePath, options);
176 const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;455 const directory = NodePath.dirname(filePath);
177 const batchWindowMs = options.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS;456 const filePrefix = providerLogPrefix(filePath);
178 const streamLabel = resolveStreamLabel(options.stream);457
179458 yield* Effect.try({
180 const directoryReady = yield* Effect.sync(() => {459 try: () => NodeFS.mkdirSync(directory, { recursive: true }),
181 try {460 catch: (cause) => new EventNdjsonLogDirectoryError({ directory, cause }),
182 NodeFS.mkdirSync(NodePath.dirname(filePath), { recursive: true });
183 return true;
184 } catch (error) {
185 return { ok: false as const, error };
186 }
187 });461 });
188 if (directoryReady !== true) {462
189 yield* logWarning("failed to create provider event log directory", {463 const initializedAt = yield* Clock.currentTimeMillis;
190 filePath,464 const initialRetention = yield* Effect.sync(() =>
191 errorTag: errorTag(directoryReady.error),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),
192 });478 });
193 return undefined;
194 }479 }
195480
196 const stateRef = yield* SynchronizedRef.make<LoggerState>({481 const stateRef = yield* SynchronizedRef.make<StoreState>({
197 threadWriters: new Map(),482 pending: [],
198 failedSegments: new Set(),483 pendingBytes: 0,
484 sinks: new Map(),
485 flushScheduled: false,
486 closed: false,
487 lastRetentionAt: initializedAt,
199 });488 });
200489 const timerScope = yield* Scope.make();
201 const resolveThreadWriter = Effect.fn("resolveThreadWriter")(function* (490
202 threadSegment: string,491 const flush = Effect.fnUntraced(function* (timerFired: boolean, close: boolean) {
203 ): Effect.fn.Return<ThreadWriter | undefined> {492 const startedAt = yield* Clock.currentTimeMillis;
204 return yield* SynchronizedRef.modifyEffect(stateRef, (state) => {493 const result = yield* SynchronizedRef.modifyEffect(stateRef, (state) =>
205 if (state.failedSegments.has(threadSegment)) {494 Effect.sync(() =>
206 return Effect.succeed([undefined, state] as const);495 drainPending({
207 }496 directory,
208497 options: resolved,
209 const existing = state.threadWriters.get(threadSegment);498 state,
210 if (existing) {499 filePrefix,
211 return Effect.succeed([existing, state] as const);500 now: startedAt,
212 }501 timerFired,
213502 close,
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;
243 }),503 }),
244 );504 ),
245 });505 );
246 });
247506
248 const write = Effect.fn("write")(function* (event: unknown, threadId: ThreadId | null) {507 for (const failure of result.failures) {
249 const threadSegment = resolveThreadSegment(threadId);508 yield* logWarning("provider event log write or retention failed", {
250 const message = yield* toLogMessage(event);509 filePath: failure.filePath,
251 if (!message) {510 errorTag: errorTag(failure.cause),
252 return;511 });
253 }512 }
254513
255 const writer = yield* resolveThreadWriter(threadSegment);514 if (resolved.attribution && result.attributions.length > 0) {
256 if (!writer) {515 const completedAt = yield* Clock.currentTimeMillis;
257 return;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 );
258 }536 }
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);
261 });550 });
262551
263 const close = Effect.fn("close")(function* () {552 const loggerViews = new Map<EventNdjsonStream, EventNdjsonLogger>();
264 yield* SynchronizedRef.modifyEffect(stateRef, (state) =>553 const logger = (stream: EventNdjsonStream): EventNdjsonLogger => {
265 Effect.gen(function* () {554 const existing = loggerViews.get(stream);
266 for (const writer of state.threadWriters.values()) {555 if (existing) return existing;
267 yield* writer.close();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);
268 }568 }
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 [595 const view = { filePath, write, close: () => Effect.void } satisfies EventNdjsonLogger;
271 undefined,596 loggerViews.set(stream, view);
272 {597 return view;
273 threadWriters: new Map<string, ThreadWriter>(),598 };
274 failedSegments: new Set<string>(),
275 },
276 ] as const;
277 }),
278 );
279 });
280599
281 return {600 return { filePath, logger, close } satisfies EventNdjsonLogStore;
282 filePath,601});
283 write,602
284 close,603export const makeEventNdjsonLogger = Effect.fnUntraced(function* (
285 } satisfies EventNdjsonLogger;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 };
286});616});
apps/server/src/provider/Layers/ProviderEventLoggers.ts+43-38
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1/**1/**
2 * ProviderEventLoggers — single observability service that owns the two2 * ProviderEventLoggers — single observability service that owns the shared
3 * shared NDJSON streams the provider runtime writes:3 * provider event log store and exposes its two runtime views:
4 *4 *
5 * - `native` — provider-protocol events as the SDK emits them, written5 * - `native` — provider-protocol events as the SDK emits them, written
6 * from inside each `<X>Adapter` factory.6 * from inside each `<X>Adapter` factory.
...@@ -13,17 +13,14 @@...@@ -13,17 +13,14 @@
13 * not at the boot Layer. There is no longer a single `make<X>AdapterLive(options)`13 * not at the boot Layer. There is no longer a single `make<X>AdapterLive(options)`
14 * call site where we can hand an `EventNdjsonLogger` in by hand.14 * call site where we can hand an `EventNdjsonLogger` in by hand.
15 * - Multiple driver instances per kind (`codex_personal`, `codex_work`)15 * - Multiple driver instances per kind (`codex_personal`, `codex_work`)
16 * should share one underlying log writer per stream — opening N writers16 * must share one underlying log store — opening N writers against the
17 * against the same rotating file would race the rotation logic. Owning17 * same rotating file would race the rotation logic. Owning the loggers on
18 * the loggers on a single tag keeps that invariant intact.18 * a single tag keeps that invariant intact.
19 * - Tests can swap one (or both) loggers with in-memory recorders by19 * - Tests can swap one (or both) loggers with in-memory recorders by
20 * `Layer.succeed(ProviderEventLoggers, { native, canonical })` instead of20 * `Layer.succeed(ProviderEventLoggers, { native, canonical })` instead of
21 * juggling per-Layer option threading.21 * juggling per-Layer option threading.
22 *22 *
23 * Both fields are optional. `makeEventNdjsonLogger` returns `undefined` when23 * Both fields are optional because observability must not prevent startup.
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`.
27 *24 *
28 * @module provider/Layers/ProviderEventLoggers25 * @module provider/Layers/ProviderEventLoggers
29 */26 */
...@@ -32,24 +29,23 @@ import * as Effect from "effect/Effect";...@@ -32,24 +29,23 @@ import * as Effect from "effect/Effect";
32import * as Layer from "effect/Layer";29import * as Layer from "effect/Layer";
3330
34import { ServerConfig } from "../../config.ts";31import { ServerConfig } from "../../config.ts";
35import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";32import * as ResourceAttribution from "../../resourceTelemetry/ResourceAttribution.ts";
3633import * as EventNdjsonLogger from "./EventNdjsonLogger.ts";
37export interface ProviderEventLoggersShape {
38 readonly native: EventNdjsonLogger | undefined;
39 readonly canonical: EventNdjsonLogger | undefined;
40}
4134
42/**35/**
43 * Shared logger pair for native + canonical provider event streams.36 * Shared logger pair for native + canonical provider event streams.
44 *37 *
45 * Service value is intentionally a struct of two optional loggers rather38 * Service value is intentionally a struct of two optional loggers rather
46 * than two parallel tags. Construction site is one place39 * than two parallel tags. Construction site is one place
47 * (`ProviderEventLoggersLive`); consumers (drivers, `ProviderService`) read40 * (`layer`); consumers (drivers, `ProviderService`) read one tag and pluck the
48 * one tag and pluck the field they need.41 * field they need.
49 */42 */
50export class ProviderEventLoggers extends Context.Service<43export class ProviderEventLoggers extends Context.Service<
51 ProviderEventLoggers,44 ProviderEventLoggers,
52 ProviderEventLoggersShape45 {
46 readonly native: EventNdjsonLogger.EventNdjsonLogger | undefined;
47 readonly canonical: EventNdjsonLogger.EventNdjsonLogger | undefined;
48 }
53>()("t3/provider/Layers/ProviderEventLoggers") {}49>()("t3/provider/Layers/ProviderEventLoggers") {}
5450
55/**51/**
...@@ -57,29 +53,38 @@ export class ProviderEventLoggers extends Context.Service<...@@ -57,29 +53,38 @@ export class ProviderEventLoggers extends Context.Service<
57 * + canonical logging entirely. Keeps the tag non-optional in the type53 * + canonical logging entirely. Keeps the tag non-optional in the type
58 * system while letting the runtime treat absence as a no-op.54 * system while letting the runtime treat absence as a no-op.
59 */55 */
60export const NoOpProviderEventLoggers: ProviderEventLoggersShape = {56export const NoOpProviderEventLoggers: ProviderEventLoggers["Service"] = {
61 native: undefined,57 native: undefined,
62 canonical: undefined,58 canonical: undefined,
63};59};
6460
65/**61/**
66 * Live Layer that builds both loggers from `ServerConfig.providerEventLogPath`.62 * Builds both stream views over one shared store. Setup failures are logged
67 * If the directory create fails for either stream, the corresponding field63 * and downgraded to the no-op service so diagnostics never block startup.
68 * is `undefined` and writes from that stream become no-ops downstream.
69 */64 */
70export const ProviderEventLoggersLive = Layer.effect(65export const make = Effect.gen(function* () {
71 ProviderEventLoggers,66 const { providerEventLogPath } = yield* ServerConfig;
72 Effect.gen(function* () {67 const attribution = yield* ResourceAttribution.ResourceAttribution;
73 const { providerEventLogPath } = yield* ServerConfig;68 const store = yield* EventNdjsonLogger.makeEventNdjsonLogStore(providerEventLogPath, {
74 const native = yield* makeEventNdjsonLogger(providerEventLogPath, {69 attribution,
75 stream: "native",70 }).pipe(
76 });71 Effect.catch((error) =>
77 const canonical = yield* makeEventNdjsonLogger(providerEventLogPath, {72 Effect.logWarning(error.message, { error }).pipe(
78 stream: "canonical",73 Effect.annotateLogs({ scope: "provider-observability" }),
79 });74 Effect.as<EventNdjsonLogger.EventNdjsonLogStore | undefined>(undefined),
80 return {75 ),
81 native,76 ),
82 canonical,77 );
83 } satisfies ProviderEventLoggersShape;78
84 }),79 if (!store) {
85);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 {...@@ -34,10 +34,13 @@ import {
34 type ProviderInstanceConfigMap,34 type ProviderInstanceConfigMap,
35 ProviderInstanceId,35 ProviderInstanceId,
36} from "@t3tools/contracts";36} from "@t3tools/contracts";
37import * as DateTime from "effect/DateTime";
37import * as Effect from "effect/Effect";38import * as Effect from "effect/Effect";
38import * as Layer from "effect/Layer";39import * as Layer from "effect/Layer";
40import * as Stream from "effect/Stream";
39import { HttpClient, HttpClientResponse } from "effect/unstable/http";41import { HttpClient, HttpClientResponse } from "effect/unstable/http";
4042
43import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
41import { ServerConfig } from "../../config.ts";44import { ServerConfig } from "../../config.ts";
42import { ServerSettingsService } from "../../serverSettings.ts";45import { ServerSettingsService } from "../../serverSettings.ts";
43import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts";46import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts";
...@@ -56,6 +59,37 @@ const TestHttpClientLive = Layer.succeed(...@@ -56,6 +59,37 @@ const TestHttpClientLive = Layer.succeed(
56 ),59 ),
57);60);
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
59const makeCodexConfig = (overrides: Partial<CodexSettings>): CodexSettings => ({93const makeCodexConfig = (overrides: Partial<CodexSettings>): CodexSettings => ({
60 enabled: false,94 enabled: false,
61 binaryPath: "codex",95 binaryPath: "codex",
...@@ -109,6 +143,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {...@@ -109,6 +143,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
109 prefix: "provider-instance-registry-test",143 prefix: "provider-instance-registry-test",
110 }).pipe(144 }).pipe(
111 Layer.provideMerge(NodeServices.layer),145 Layer.provideMerge(NodeServices.layer),
146 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
112 Layer.provideMerge(ServerSettingsService.layerTest()),147 Layer.provideMerge(ServerSettingsService.layerTest()),
113 Layer.provideMerge(TestHttpClientLive),148 Layer.provideMerge(TestHttpClientLive),
114 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),149 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
...@@ -247,6 +282,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {...@@ -247,6 +282,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
247 prefix: "provider-instance-registry-all-drivers-test",282 prefix: "provider-instance-registry-all-drivers-test",
248 }).pipe(283 }).pipe(
249 Layer.provideMerge(infraLayer),284 Layer.provideMerge(infraLayer),
285 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
250 Layer.provideMerge(ServerSettingsService.layerTest()),286 Layer.provideMerge(ServerSettingsService.layerTest()),
251 Layer.provideMerge(TestHttpClientLive),287 Layer.provideMerge(TestHttpClientLive),
252 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),288 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
apps/server/src/provider/Layers/ProviderRegistry.test.ts+44
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, it, assert } from "@effect/vitest";2import { describe, it, assert } from "@effect/vitest";
3import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";5import * as Exit from "effect/Exit";
5import * as Fiber from "effect/Fiber";6import * as Fiber from "effect/Fiber";
...@@ -32,6 +33,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";...@@ -32,6 +33,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
3233
33import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts";34import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts";
34import { checkClaudeProviderStatus } from "./ClaudeProvider.ts";35import { checkClaudeProviderStatus } from "./ClaudeProvider.ts";
36import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
35import * as OpenCodeRuntime from "../opencodeRuntime.ts";37import * as OpenCodeRuntime from "../opencodeRuntime.ts";
36import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";38import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";
37import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts";39import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts";
...@@ -65,6 +67,7 @@ process.env.T3CODE_CURSOR_ENABLED = "1";...@@ -65,6 +67,7 @@ process.env.T3CODE_CURSOR_ENABLED = "1";
65// ── Test helpers ────────────────────────────────────────────────────67// ── Test helpers ────────────────────────────────────────────────────
6668
67const encoder = new TextEncoder();69const encoder = new TextEncoder();
70const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
6871
69const TestHttpClientLive = Layer.succeed(72const TestHttpClientLive = Layer.succeed(
70 HttpClient.HttpClient,73 HttpClient.HttpClient,
...@@ -73,6 +76,35 @@ const TestHttpClientLive = Layer.succeed(...@@ -73,6 +76,35 @@ const TestHttpClientLive = Layer.succeed(
73 ),76 ),
74);77);
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
76function selectDescriptor(108function selectDescriptor(
77 id: string,109 id: string,
78 label: string,110 label: string,
...@@ -297,6 +329,11 @@ function makeMutableServerSettingsService(...@@ -297,6 +329,11 @@ function makeMutableServerSettingsService(
297 get streamChanges() {329 get streamChanges() {
298 return Stream.fromPubSub(changes);330 return Stream.fromPubSub(changes);
299 },331 },
332 get subscribeChanges() {
333 return PubSub.subscribe(changes).pipe(
334 Effect.map((subscription) => Stream.fromSubscription(subscription)),
335 );
336 },
300 } satisfies ServerSettingsModule.ServerSettingsService["Service"];337 } satisfies ServerSettingsModule.ServerSettingsService["Service"];
301 });338 });
302}339}
...@@ -1000,6 +1037,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1000,6 +1037,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1000 prefix: "t3-provider-registry-merged-persist-",1037 prefix: "t3-provider-registry-merged-persist-",
1001 }),1038 }),
1002 ),1039 ),
1040 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
1003 Layer.provideMerge(NodeServices.layer),1041 Layer.provideMerge(NodeServices.layer),
1004 ),1042 ),
1005 ).pipe(Scope.provide(scope));1043 ).pipe(Scope.provide(scope));
...@@ -1235,6 +1273,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1235,6 +1273,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1235 prefix: "t3-provider-registry-refresh-failure-",1273 prefix: "t3-provider-registry-refresh-failure-",
1236 }),1274 }),
1237 ),1275 ),
1276 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
1238 Layer.provideMerge(NodeServices.layer),1277 Layer.provideMerge(NodeServices.layer),
1239 ),1278 ),
1240 ).pipe(Scope.provide(scope));1279 ).pipe(Scope.provide(scope));
...@@ -1342,6 +1381,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1342,6 +1381,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1342 prefix: "t3-provider-registry-sync-failure-",1381 prefix: "t3-provider-registry-sync-failure-",
1343 }),1382 }),
1344 ),1383 ),
1384 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
1345 Layer.provideMerge(NodeServices.layer),1385 Layer.provideMerge(NodeServices.layer),
1346 ),1386 ),
1347 ).pipe(Scope.provide(scope));1387 ).pipe(Scope.provide(scope));
...@@ -1446,6 +1486,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1446,6 +1486,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1446 ),1486 ),
1447 ),1487 ),
1448 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),1488 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
1489 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
1449 // NO spawner mock — `ChildProcessSpawner` is supplied by the1490 // NO spawner mock — `ChildProcessSpawner` is supplied by the
1450 // outer `NodeServices.layer` on `it.layer(...)` and will1491 // outer `NodeServices.layer` on `it.layer(...)` and will
1451 // genuinely spawn a subprocess. The missing-binary ENOENT is1492 // genuinely spawn a subprocess. The missing-binary ENOENT is
...@@ -1545,6 +1586,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1545,6 +1586,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1545 }),1586 }),
1546 ),1587 ),
1547 Layer.provideMerge(NodeServices.layer),1588 Layer.provideMerge(NodeServices.layer),
1589 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
1548 );1590 );
1549 const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe(1591 const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe(
1550 Scope.provide(scope),1592 Scope.provide(scope),
...@@ -1660,6 +1702,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1660,6 +1702,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1660 ),1702 ),
1661 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),1703 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
1662 Layer.provideMerge(NodeServices.layer),1704 Layer.provideMerge(NodeServices.layer),
1705 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
1663 );1706 );
1664 const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe(1707 const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe(
1665 Scope.provide(scope),1708 Scope.provide(scope),
...@@ -1720,6 +1763,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1720,6 +1763,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1720 ),1763 ),
1721 ),1764 ),
1722 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),1765 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
1766 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
1723 Layer.provideMerge(1767 Layer.provideMerge(
1724 mockCommandSpawnerLayer((command, args) => {1768 mockCommandSpawnerLayer((command, args) => {
1725 if (command === "cursor-agent") {1769 if (command === "cursor-agent") {
apps/server/src/provider/makeManagedServerProvider.test.ts+172-5
...@@ -1,16 +1,28 @@...@@ -1,16 +1,28 @@
1import { describe, it, assert } from "@effect/vitest";1import { 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";
3import { createModelCapabilities } from "@t3tools/shared/model";8import { createModelCapabilities } from "@t3tools/shared/model";
9import * as DateTime from "effect/DateTime";
4import * as Deferred from "effect/Deferred";10import * as Deferred from "effect/Deferred";
11import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";13import * as Fiber from "effect/Fiber";
14import * as Layer from "effect/Layer";
7import * as PubSub from "effect/PubSub";15import * as PubSub from "effect/PubSub";
8import * as Ref from "effect/Ref";16import * as Ref from "effect/Ref";
9import * as Stream from "effect/Stream";17import * as Stream from "effect/Stream";
18import { TestClock } from "effect/testing";
1019
20import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
21import { ServerSettingsService } from "../serverSettings.ts";
11import { makeManagedServerProvider } from "./makeManagedServerProvider.ts";22import { makeManagedServerProvider } from "./makeManagedServerProvider.ts";
1223
13const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] });24const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] });
25const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
14const fastModeCapabilities = createModelCapabilities({26const fastModeCapabilities = createModelCapabilities({
15 optionDescriptors: [27 optionDescriptors: [
16 {28 {
...@@ -87,6 +99,43 @@ const refreshedSnapshotSecond: ServerProvider = {...@@ -87,6 +99,43 @@ const refreshedSnapshotSecond: ServerProvider = {
87 message: "Refreshed provider availability again.",99 message: "Refreshed provider availability again.",
88};100};
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
90const enrichedSnapshotSecond: ServerProvider = {139const enrichedSnapshotSecond: ServerProvider = {
91 ...refreshedSnapshotSecond,140 ...refreshedSnapshotSecond,
92 checkedAt: "2026-04-10T00:00:04.000Z",141 checkedAt: "2026-04-10T00:00:04.000Z",
...@@ -140,7 +189,125 @@ describe("makeManagedServerProvider", () => {...@@ -140,7 +189,125 @@ describe("makeManagedServerProvider", () => {
140 assert.deepStrictEqual(latest, refreshedSnapshot);189 assert.deepStrictEqual(latest, refreshedSnapshot);
141 assert.strictEqual(yield* Ref.get(checkCalls), 1);190 assert.strictEqual(yield* Ref.get(checkCalls), 1);
142 }),191 }),
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())),
144 );311 );
145312
146 it.effect("reruns the provider check when streamed settings change", () =>313 it.effect("reruns the provider check when streamed settings change", () =>
...@@ -185,7 +352,7 @@ describe("makeManagedServerProvider", () => {...@@ -185,7 +352,7 @@ describe("makeManagedServerProvider", () => {
185 assert.deepStrictEqual(latest, refreshedSnapshotSecond);352 assert.deepStrictEqual(latest, refreshedSnapshotSecond);
186 assert.strictEqual(yield* Ref.get(checkCalls), 2);353 assert.strictEqual(yield* Ref.get(checkCalls), 2);
187 }),354 }),
188 ),355 ).pipe(Effect.provide(AlwaysRunTestLayer)),
189 );356 );
190357
191 it.effect("streams supplemental snapshot updates after the base provider check completes", () =>358 it.effect("streams supplemental snapshot updates after the base provider check completes", () =>
...@@ -223,7 +390,7 @@ describe("makeManagedServerProvider", () => {...@@ -223,7 +390,7 @@ describe("makeManagedServerProvider", () => {
223 assert.deepStrictEqual(updates, [refreshedSnapshot, enrichedSnapshot]);390 assert.deepStrictEqual(updates, [refreshedSnapshot, enrichedSnapshot]);
224 assert.deepStrictEqual(latest, enrichedSnapshot);391 assert.deepStrictEqual(latest, enrichedSnapshot);
225 }),392 }),
226 ),393 ).pipe(Effect.provide(AlwaysRunTestLayer)),
227 );394 );
228395
229 it.effect("ignores stale enrichment callbacks after a newer refresh advances generation", () =>396 it.effect("ignores stale enrichment callbacks after a newer refresh advances generation", () =>
...@@ -284,6 +451,6 @@ describe("makeManagedServerProvider", () => {...@@ -284,6 +451,6 @@ describe("makeManagedServerProvider", () => {
284 ]);451 ]);
285 assert.deepStrictEqual(latest, enrichedSnapshotSecond);452 assert.deepStrictEqual(latest, enrichedSnapshotSecond);
286 }),453 }),
287 ),454 ).pipe(Effect.provide(AlwaysRunTestLayer)),
288 );455 );
289});456});
apps/server/src/provider/makeManagedServerProvider.ts+73-5
...@@ -1,16 +1,23 @@...@@ -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";
2import * as Duration from "effect/Duration";7import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
4import * as Equal from "effect/Equal";9import * as Equal from "effect/Equal";
5import * as Fiber from "effect/Fiber";10import * as Fiber from "effect/Fiber";
6import * as PubSub from "effect/PubSub";11import * as PubSub from "effect/PubSub";
12import * as Queue from "effect/Queue";
7import * as Ref from "effect/Ref";13import * as Ref from "effect/Ref";
8import * as Scope from "effect/Scope";14import * as Scope from "effect/Scope";
9import * as Stream from "effect/Stream";15import * as Stream from "effect/Stream";
10import * as Semaphore from "effect/Semaphore";16import * as Semaphore from "effect/Semaphore";
1117
18import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
19import { ServerSettingsService } from "../serverSettings.ts";
12import type { ServerProviderShape } from "./Services/ServerProvider.ts";20import type { ServerProviderShape } from "./Services/ServerProvider.ts";
13import { ServerSettingsError } from "@t3tools/contracts";
1421
15interface ProviderSnapshotState {22interface ProviderSnapshotState {
16 readonly snapshot: ServerProvider;23 readonly snapshot: ServerProvider;
...@@ -33,7 +40,13 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(...@@ -33,7 +40,13 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(
33 readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>;40 readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>;
34 }) => Effect.Effect<void>;41 }) => Effect.Effect<void>;
35 readonly refreshInterval?: Duration.Input;42 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;
37 const refreshSemaphore = yield* Semaphore.make(1);50 const refreshSemaphore = yield* Semaphore.make(1);
38 const changesPubSub = yield* Effect.acquireRelease(51 const changesPubSub = yield* Effect.acquireRelease(
39 PubSub.unbounded<ServerProvider>(),52 PubSub.unbounded<ServerProvider>(),
...@@ -134,13 +147,68 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(...@@ -134,13 +147,68 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(
134 return yield* applySnapshot(nextSettings, { forceRefresh: true });147 return yield* applySnapshot(nextSettings, { forceRefresh: true });
135 });148 });
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
137 yield* Stream.runForEach(input.streamSettings, (nextSettings) =>186 yield* Stream.runForEach(input.streamSettings, (nextSettings) =>
138 Effect.asVoid(applySnapshot(nextSettings)),187 Effect.asVoid(applySnapshot(nextSettings)),
139 ).pipe(Effect.forkScoped);188 ).pipe(Effect.forkScoped);
140189
141 yield* Effect.forever(190 yield* Effect.forever(
142 Effect.sleep(input.refreshInterval ?? "60 seconds").pipe(191 getRefreshInterval.pipe(
143 Effect.flatMap(() => refreshSnapshot()),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 ),
144 Effect.ignoreCause({ log: true }),212 Effect.ignoreCause({ log: true }),
145 ),213 ),
146 ).pipe(Effect.forkScoped);214 ).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";...@@ -71,6 +71,7 @@ import { vi } from "vite-plus/test";
7171
72const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");72const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
7373
74import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
74import * as ServerConfig from "./config.ts";75import * as ServerConfig from "./config.ts";
75import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts";76import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts";
76import { makeRoutesLayer } from "./server.ts";77import { makeRoutesLayer } from "./server.ts";
...@@ -116,6 +117,10 @@ import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";...@@ -116,6 +117,10 @@ import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";
116import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";117import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
117import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";118import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
118import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";119import * 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";
119import * as Data from "effect/Data";124import * as Data from "effect/Data";
120125
121const defaultProjectId = ProjectId.make("project-default");126const defaultProjectId = ProjectId.make("project-default");
...@@ -355,6 +360,10 @@ const buildAppUnderTest = (options?: {...@@ -355,6 +360,10 @@ const buildAppUnderTest = (options?: {
355 >;360 >;
356 relayClient?: Partial<RelayClient.RelayClient["Service"]>;361 relayClient?: Partial<RelayClient.RelayClient["Service"]>;
357 cloudCliTokenManager?: Partial<CloudCliTokenManager.CloudCliTokenManager["Service"]>;362 cloudCliTokenManager?: Partial<CloudCliTokenManager.CloudCliTokenManager["Service"]>;
363 nativeTelemetryClient?: Partial<NativeTelemetryClient.NativeTelemetryClient["Service"]>;
364 desktopTelemetryReceiver?: Partial<
365 DesktopTelemetryReceiver.DesktopTelemetryReceiver["Service"]
366 >;
358 };367 };
359}) =>368}) =>
360 Effect.gen(function* () {369 Effect.gen(function* () {
...@@ -531,6 +540,15 @@ const buildAppUnderTest = (options?: {...@@ -531,6 +540,15 @@ const buildAppUnderTest = (options?: {
531 ...options.layers.vcsStatusBroadcaster,540 ...options.layers.vcsStatusBroadcaster,
532 })541 })
533 : VcsStatusBroadcaster.layer.pipe(Layer.provide(gitWorkflowLayer));542 : 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
535 const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, {553 const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, {
536 disableListenLog: true,554 disableListenLog: true,
...@@ -745,6 +763,7 @@ const buildAppUnderTest = (options?: {...@@ -745,6 +763,7 @@ const buildAppUnderTest = (options?: {
745 );763 );
746764
747 const appLayer = servedRoutesLayer.pipe(765 const appLayer = servedRoutesLayer.pipe(
766 Layer.provide(resourceTelemetryLayer),
748 Layer.provide(767 Layer.provide(
749 Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({768 Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({
750 record: () => Effect.void,769 record: () => Effect.void,
...@@ -767,6 +786,58 @@ const buildAppUnderTest = (options?: {...@@ -767,6 +786,58 @@ const buildAppUnderTest = (options?: {
767 ...options?.layers?.serverRuntimeStartup,786 ...options?.layers?.serverRuntimeStartup,
768 }),787 }),
769 ),788 ),
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 ),
770 Layer.provide(841 Layer.provide(
771 Layer.mock(ServerEnvironment.ServerEnvironment)({842 Layer.mock(ServerEnvironment.ServerEnvironment)({
772 getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId),843 getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId),
...@@ -4395,6 +4466,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -4395,6 +4466,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
4395 }).pipe(Effect.provide(NodeHttpServer.layerTest)),4466 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
4396 );4467 );
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
4398 it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () =>4486 it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () =>
4399 Effect.gen(function* () {4487 Effect.gen(function* () {
4400 const nextProviders = [4488 const nextProviders = [
apps/server/src/server.ts+46-6
...@@ -4,6 +4,8 @@ import * as Layer from "effect/Layer";...@@ -4,6 +4,8 @@ import * as Layer from "effect/Layer";
4import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http";4import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http";
5import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";5import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
66
7import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
8import * as HostPowerMonitor from "./background/HostPowerMonitor.ts";
7import * as ServerConfig from "./config.ts";9import * as ServerConfig from "./config.ts";
8import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts";10import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts";
9import {11import {
...@@ -90,6 +92,11 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";...@@ -90,6 +92,11 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";
90import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";92import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
91import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";93import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
92import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";94import * 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";
93import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";100import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
94import {101import {
95 clearPersistedServerRuntimeState,102 clearPersistedServerRuntimeState,
...@@ -106,6 +113,10 @@ import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"...@@ -106,6 +113,10 @@ import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"
106// already closes the websocket gracefully. Do not add an artificial drain before113// already closes the websocket gracefully. Do not add an artificial drain before
107// those finalizers get a chance to run.114// those finalizers get a chance to run.
108const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0;115const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0;
116const ResourceAttributionLayerLive = ResourceAttribution.layer;
117const ApplicationObservabilityLive = ObservabilityLive.pipe(
118 Layer.provideMerge(ResourceAttributionLayerLive),
119);
109120
110const PtyAdapterLive = Layer.unwrap(121const PtyAdapterLive = Layer.unwrap(
111 Effect.gen(function* () {122 Effect.gen(function* () {
...@@ -119,6 +130,35 @@ const PtyAdapterLive = Layer.unwrap(...@@ -119,6 +130,35 @@ const PtyAdapterLive = Layer.unwrap(
119 }),130 }),
120);131);
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
122const RelayClientLive = Layer.unwrap(162const RelayClientLive = Layer.unwrap(
123 Effect.gen(function* () {163 Effect.gen(function* () {
124 const config = yield* ServerConfig.ServerConfig;164 const config = yield* ServerConfig.ServerConfig;
...@@ -184,7 +224,7 @@ const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe(...@@ -184,7 +224,7 @@ const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe(
184// `ProviderAdapterRegistryLive` is now a facade that resolves kind → adapter224// `ProviderAdapterRegistryLive` is now a facade that resolves kind → adapter
185// by looking up the default `ProviderInstance` per driver in the instance225// by looking up the default `ProviderInstance` per driver in the instance
186// registry. Adapter construction itself moved inside each driver's226// registry. Adapter construction itself moved inside each driver's
187// `create()`; `ProviderEventLoggersLive` owns the shared native/canonical227// `create()`; `ProviderEventLoggers.layer` owns the shared native/canonical
188// NDJSON writers and is provided at the outer runtime layer so both228// NDJSON writers and is provided at the outer runtime layer so both
189// `ProviderService` and the per-instance drivers read the same logger pair.229// `ProviderService` and the per-instance drivers read the same logger pair.
190const ProviderLayerLive = ProviderServiceLive.pipe(230const ProviderLayerLive = ProviderServiceLive.pipe(
...@@ -298,6 +338,7 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe(...@@ -298,6 +338,7 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe(
298338
299const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(339const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
300 // Core Services340 // Core Services
341 Layer.provideMerge(ServerSettingsLayerLive),
301 Layer.provideMerge(CheckpointingLayerLive),342 Layer.provideMerge(CheckpointingLayerLive),
302 Layer.provideMerge(SourceControlProviderRegistryLayerLive),343 Layer.provideMerge(SourceControlProviderRegistryLayerLive),
303 Layer.provideMerge(GitLayerLive),344 Layer.provideMerge(GitLayerLive),
...@@ -318,14 +359,13 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(...@@ -318,14 +359,13 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
318 // `ProviderService` (canonical stream, written after event normalization).359 // `ProviderService` (canonical stream, written after event normalization).
319 // Provided once at the runtime level so every consumer sees the same360 // Provided once at the runtime level so every consumer sees the same
320 // logger instances.361 // logger instances.
321 Layer.provideMerge(ProviderEventLoggers.ProviderEventLoggersLive),362 Layer.provideMerge(ProviderEventLoggers.layer),
322 // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old363 // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old
323 // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but364 // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but
324 // the rewritten registry reads snapshots off the instance registry and365 // the rewritten registry reads snapshots off the instance registry and
325 // no longer transitively provides it. Exposing it at the runtime level366 // no longer transitively provides it. Exposing it at the runtime level
326 // keeps a single Live for all opencode consumers.367 // keeps a single Live for all opencode consumers.
327 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),368 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
328 Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))),
329 Layer.provideMerge(WorkspaceLayerLive),369 Layer.provideMerge(WorkspaceLayerLive),
330 Layer.provideMerge(ProjectFaviconResolverLayerLive),370 Layer.provideMerge(ProjectFaviconResolverLayerLive),
331 Layer.provideMerge(RepositoryIdentityResolver.layer),371 Layer.provideMerge(RepositoryIdentityResolver.layer),
...@@ -345,8 +385,8 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(...@@ -345,8 +385,8 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
345385
346const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(386const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(
347 // Misc.387 // Misc.
348 Layer.provideMerge(ProcessDiagnostics.layer),388 Layer.provideMerge(BackgroundLayerLive),
349 Layer.provideMerge(ProcessResourceMonitor.layer),389 Layer.provideMerge(ResourceDiagnosticsLayerLive),
350 Layer.provideMerge(TraceDiagnostics.layer),390 Layer.provideMerge(TraceDiagnostics.layer),
351 Layer.provideMerge(AnalyticsService.layer),391 Layer.provideMerge(AnalyticsService.layer),
352 Layer.provideMerge(ExternalLauncher.layer),392 Layer.provideMerge(ExternalLauncher.layer),
...@@ -522,7 +562,7 @@ export const makeServerLayer = Layer.unwrap(...@@ -522,7 +562,7 @@ export const makeServerLayer = Layer.unwrap(
522 Layer.provideMerge(serverRelayBrokerTracingLayer),562 Layer.provideMerge(serverRelayBrokerTracingLayer),
523 Layer.provideMerge(HttpResponseCompressionLive),563 Layer.provideMerge(HttpResponseCompressionLive),
524 Layer.provideMerge(HttpServerLive),564 Layer.provideMerge(HttpServerLive),
525 Layer.provide(ObservabilityLive),565 Layer.provide(ApplicationObservabilityLive),
526 Layer.provideMerge(FetchHttpClient.layer),566 Layer.provideMerge(FetchHttpClient.layer),
527 Layer.provideMerge(VcsProcess.layer),567 Layer.provideMerge(VcsProcess.layer),
528 Layer.provideMerge(PlatformServicesLive),568 Layer.provideMerge(PlatformServicesLive),
apps/server/src/serverSettings.test.ts+33
...@@ -12,8 +12,10 @@ import * as Effect from "effect/Effect";...@@ -12,8 +12,10 @@ import * as Effect from "effect/Effect";
12import * as Duration from "effect/Duration";12import * as Duration from "effect/Duration";
13import * as FileSystem from "effect/FileSystem";13import * as FileSystem from "effect/FileSystem";
14import * as Layer from "effect/Layer";14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
15import * as PlatformError from "effect/PlatformError";16import * as PlatformError from "effect/PlatformError";
16import * as Schema from "effect/Schema";17import * as Schema from "effect/Schema";
18import * as Stream from "effect/Stream";
17import * as ServerSecretStore from "./auth/ServerSecretStore.ts";19import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
18import * as ServerConfig from "./config.ts";20import * as ServerConfig from "./config.ts";
19import * as ServerSettingsModule from "./serverSettings.ts";21import * as ServerSettingsModule from "./serverSettings.ts";
...@@ -203,6 +205,29 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -203,6 +205,29 @@ it.layer(NodeServices.layer)("server settings", (it) => {
203 }).pipe(Effect.provide(makeServerSettingsLayer())),205 }).pipe(Effect.provide(makeServerSettingsLayer())),
204 );206 );
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
206 it.effect("preserves model when switching providers via textGenerationModelSelection", () =>231 it.effect("preserves model when switching providers via textGenerationModelSelection", () =>
207 Effect.gen(function* () {232 Effect.gen(function* () {
208 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;233 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
...@@ -590,6 +615,14 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -590,6 +615,14 @@ it.layer(NodeServices.layer)("server settings", (it) => {
590 serverPassword: "secret-password",615 serverPassword: "secret-password",
591 },616 },
592 },617 },
618 backgroundActivity: {
619 schemaVersion: 1,
620 profile: "custom",
621 baseProfile: "balanced",
622 overrides: {
623 automaticGitFetchInterval: 10_000,
624 },
625 },
593 automaticGitFetchInterval: 10_000,626 automaticGitFetchInterval: 10_000,
594 });627 });
595 }).pipe(Effect.provide(makeServerSettingsLayer())),628 }).pipe(Effect.provide(makeServerSettingsLayer())),
apps/server/src/serverSettings.ts+37-15
...@@ -131,6 +131,13 @@ export class ServerSettingsService extends Context.Service<...@@ -131,6 +131,13 @@ export class ServerSettingsService extends Context.Service<
131131
132 /** Stream of settings change events. */132 /** Stream of settings change events. */
133 readonly streamChanges: Stream.Stream<ServerSettings>;133 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>;
134 }141 }
135>()("t3/serverSettings/ServerSettingsService") {142>()("t3/serverSettings/ServerSettingsService") {
136 /** @deprecated Import and use `layerTest` from this module. */143 /** @deprecated Import and use `layerTest` from this module. */
...@@ -139,13 +146,17 @@ export class ServerSettingsService extends Context.Service<...@@ -139,13 +146,17 @@ export class ServerSettingsService extends Context.Service<
139146
140const makeTest = (overrides: DeepPartial<ServerSettings> = {}) =>147const makeTest = (overrides: DeepPartial<ServerSettings> = {}) =>
141 Effect.gen(function* () {148 Effect.gen(function* () {
142 const { automaticGitFetchInterval, ...overridesForMerge } = overrides;149 const { automaticGitFetchInterval, providerHealthRefreshInterval, ...overridesForMerge } =
150 overrides;
143 const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge);151 const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge);
144 const initialSettings = yield* normalizeServerSettings({152 const initialSettings = yield* normalizeServerSettings({
145 ...merged,153 ...merged,
146 ...(automaticGitFetchInterval !== undefined154 ...(automaticGitFetchInterval !== undefined
147 ? { automaticGitFetchInterval: automaticGitFetchInterval as Duration.Duration }155 ? { automaticGitFetchInterval: automaticGitFetchInterval as Duration.Duration }
148 : {}),156 : {}),
157 ...(providerHealthRefreshInterval !== undefined
158 ? { providerHealthRefreshInterval: providerHealthRefreshInterval as Duration.Duration }
159 : {}),
149 });160 });
150 const currentSettingsRef = yield* Ref.make<ServerSettings>(initialSettings);161 const currentSettingsRef = yield* Ref.make<ServerSettings>(initialSettings);
151162
...@@ -161,6 +172,7 @@ const makeTest = (overrides: DeepPartial<ServerSettings> = {}) =>...@@ -161,6 +172,7 @@ const makeTest = (overrides: DeepPartial<ServerSettings> = {}) =>
161 Effect.map(resolveTextGenerationProvider),172 Effect.map(resolveTextGenerationProvider),
162 ),173 ),
163 streamChanges: Stream.empty,174 streamChanges: Stream.empty,
175 subscribeChanges: Effect.succeed(Stream.empty),
164 } satisfies ServerSettingsService["Service"];176 } satisfies ServerSettingsService["Service"];
165 });177 });
166178
...@@ -197,7 +209,9 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting...@@ -197,7 +209,9 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting
197209
198// Values under these keys are compared as a whole — never stripped field-by-field.210// Values under these keys are compared as a whole — never stripped field-by-field.
199const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([211const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([
212 "backgroundActivity",
200 "automaticGitFetchInterval",213 "automaticGitFetchInterval",
214 "providerHealthRefreshInterval",
201 "sourceControlWriterModelSelection",215 "sourceControlWriterModelSelection",
202 "textGenerationModelSelection",216 "textGenerationModelSelection",
203]);217]);
...@@ -344,6 +358,23 @@ const make = Effect.gen(function* () {...@@ -344,6 +358,23 @@ const make = Effect.gen(function* () {
344 };358 };
345 });359 });
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
347 const persistProviderEnvironmentSecrets = (378 const persistProviderEnvironmentSecrets = (
348 current: ServerSettings,379 current: ServerSettings,
349 next: ServerSettings,380 next: ServerSettings,
...@@ -561,20 +592,11 @@ const make = Effect.gen(function* () {...@@ -561,20 +592,11 @@ const make = Effect.gen(function* () {
561 }),592 }),
562 ),593 ),
563 get streamChanges() {594 get streamChanges() {
564 return Stream.fromPubSub(changesPubSub).pipe(595 return materializeChanges(Stream.fromPubSub(changesPubSub));
565 Stream.mapEffect((settings) =>596 },
566 materializeProviderEnvironmentSecrets(settings).pipe(597 get subscribeChanges() {
567 Effect.catch((error: ServerSettingsError) =>598 return PubSub.subscribe(changesPubSub).pipe(
568 Effect.logWarning("failed to materialize provider environment secrets", {599 Effect.map((subscription) => materializeChanges(Stream.fromSubscription(subscription))),
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),
578 );600 );
579 },601 },
580 } satisfies ServerSettingsService["Service"];602 } 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";...@@ -2,6 +2,7 @@ import { assert, it, describe } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Cause from "effect/Cause";3import * as Cause from "effect/Cause";
4import * as Deferred from "effect/Deferred";4import * as Deferred from "effect/Deferred";
5import * as DateTime from "effect/DateTime";
5import * as Duration from "effect/Duration";6import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
7import * as Exit from "effect/Exit";8import * as Exit from "effect/Exit";
...@@ -14,6 +15,7 @@ import * as Scope from "effect/Scope";...@@ -14,6 +15,7 @@ import * as Scope from "effect/Scope";
14import * as Stream from "effect/Stream";15import * as Stream from "effect/Stream";
15import * as TestClock from "effect/testing/TestClock";16import * as TestClock from "effect/testing/TestClock";
16import type {17import type {
18 BackgroundScope,
17 VcsStatusLocalResult,19 VcsStatusLocalResult,
18 VcsStatusRemoteResult,20 VcsStatusRemoteResult,
19 VcsStatusResult,21 VcsStatusResult,
...@@ -22,8 +24,11 @@ import type {...@@ -22,8 +24,11 @@ import type {
22import { GitManagerError } from "@t3tools/contracts";24import { GitManagerError } from "@t3tools/contracts";
2325
24import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts";26import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts";
27import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
25import * as GitWorkflowService from "../git/GitWorkflowService.ts";28import * as GitWorkflowService from "../git/GitWorkflowService.ts";
2629
30const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
31
27const baseLocalStatus: VcsStatusLocalResult = {32const baseLocalStatus: VcsStatusLocalResult = {
28 isRepo: true,33 isRepo: true,
29 sourceControlProvider: {34 sourceControlProvider: {
...@@ -73,6 +78,7 @@ function makeTestLayer(state: {...@@ -73,6 +78,7 @@ function makeTestLayer(state: {
73}) {78}) {
74 return VcsStatusBroadcaster.layer.pipe(79 return VcsStatusBroadcaster.layer.pipe(
75 Layer.provideMerge(NodeServices.layer),80 Layer.provideMerge(NodeServices.layer),
81 Layer.provide(makeBackgroundPolicyLayer(() => true)),
76 Layer.provide(82 Layer.provide(
77 Layer.mock(GitWorkflowService.GitWorkflowService)({83 Layer.mock(GitWorkflowService.GitWorkflowService)({
78 localStatus: () =>84 localStatus: () =>
...@@ -104,6 +110,37 @@ function makeTestLayer(state: {...@@ -104,6 +110,37 @@ function makeTestLayer(state: {
104 );110 );
105}111}
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
107describe("VcsStatusBroadcaster", () => {144describe("VcsStatusBroadcaster", () => {
108 it.effect("reuses the cached VCS status across repeated reads", () => {145 it.effect("reuses the cached VCS status across repeated reads", () => {
109 const state = {146 const state = {
...@@ -183,6 +220,7 @@ describe("VcsStatusBroadcaster", () => {...@@ -183,6 +220,7 @@ describe("VcsStatusBroadcaster", () => {
183 };220 };
184 const testLayer = VcsStatusBroadcaster.layer.pipe(221 const testLayer = VcsStatusBroadcaster.layer.pipe(
185 Layer.provideMerge(NodeServices.layer),222 Layer.provideMerge(NodeServices.layer),
223 Layer.provide(makeBackgroundPolicyLayer(() => true)),
186 Layer.provide(224 Layer.provide(
187 Layer.mock(GitWorkflowService.GitWorkflowService)({225 Layer.mock(GitWorkflowService.GitWorkflowService)({
188 localStatus: () =>226 localStatus: () =>
...@@ -290,6 +328,7 @@ describe("VcsStatusBroadcaster", () => {...@@ -290,6 +328,7 @@ describe("VcsStatusBroadcaster", () => {
290 };328 };
291 const testLayer = VcsStatusBroadcaster.layer.pipe(329 const testLayer = VcsStatusBroadcaster.layer.pipe(
292 Layer.provideMerge(NodeServices.layer),330 Layer.provideMerge(NodeServices.layer),
331 Layer.provide(makeBackgroundPolicyLayer(() => true)),
293 Layer.provide(332 Layer.provide(
294 Layer.mock(GitWorkflowService.GitWorkflowService)({333 Layer.mock(GitWorkflowService.GitWorkflowService)({
295 localStatus: (input) =>334 localStatus: (input) =>
...@@ -453,6 +492,7 @@ describe("VcsStatusBroadcaster", () => {...@@ -453,6 +492,7 @@ describe("VcsStatusBroadcaster", () => {
453 let firstRemoteAttemptDeferred: Deferred.Deferred<void> | null = null;492 let firstRemoteAttemptDeferred: Deferred.Deferred<void> | null = null;
454 const testLayer = VcsStatusBroadcaster.layer.pipe(493 const testLayer = VcsStatusBroadcaster.layer.pipe(
455 Layer.provideMerge(NodeServices.layer),494 Layer.provideMerge(NodeServices.layer),
495 Layer.provide(makeBackgroundPolicyLayer(() => true)),
456 Layer.provide(496 Layer.provide(
457 Layer.mock(GitWorkflowService.GitWorkflowService)({497 Layer.mock(GitWorkflowService.GitWorkflowService)({
458 localStatus: () =>498 localStatus: () =>
...@@ -641,6 +681,57 @@ describe("VcsStatusBroadcaster", () => {...@@ -641,6 +681,57 @@ describe("VcsStatusBroadcaster", () => {
641 });681 });
642 });682 });
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
644 it.effect("stops the remote poller after the last stream subscriber disconnects", () => {735 it.effect("stops the remote poller after the last stream subscriber disconnects", () => {
645 const state = {736 const state = {
646 currentLocalStatus: baseLocalStatus,737 currentLocalStatus: baseLocalStatus,
...@@ -654,6 +745,7 @@ describe("VcsStatusBroadcaster", () => {...@@ -654,6 +745,7 @@ describe("VcsStatusBroadcaster", () => {
654 let remoteStartedDeferred: Deferred.Deferred<void, never> | null = null;745 let remoteStartedDeferred: Deferred.Deferred<void, never> | null = null;
655 const testLayer = VcsStatusBroadcaster.layer.pipe(746 const testLayer = VcsStatusBroadcaster.layer.pipe(
656 Layer.provideMerge(NodeServices.layer),747 Layer.provideMerge(NodeServices.layer),
748 Layer.provide(makeBackgroundPolicyLayer(() => true)),
657 Layer.provide(749 Layer.provide(
658 Layer.mock(GitWorkflowService.GitWorkflowService)({750 Layer.mock(GitWorkflowService.GitWorkflowService)({
659 localStatus: () =>751 localStatus: () =>
apps/server/src/vcs/VcsStatusBroadcaster.ts+82-28
...@@ -22,6 +22,7 @@ import type {...@@ -22,6 +22,7 @@ import type {
22} from "@t3tools/contracts";22} from "@t3tools/contracts";
23import { mergeGitStatusParts } from "@t3tools/shared/git";23import { mergeGitStatusParts } from "@t3tools/shared/git";
2424
25import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
25import * as GitWorkflowService from "../git/GitWorkflowService.ts";26import * as GitWorkflowService from "../git/GitWorkflowService.ts";
2627
27const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30);28const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30);
...@@ -131,6 +132,7 @@ interface CachedVcsStatus {...@@ -131,6 +132,7 @@ interface CachedVcsStatus {
131interface ActiveRemotePoller {132interface ActiveRemotePoller {
132 readonly fiber: Fiber.Fiber<void, never>;133 readonly fiber: Fiber.Fiber<void, never>;
133 readonly subscriberCount: number;134 readonly subscriberCount: number;
135 readonly demandCwds: Ref.Ref<ReadonlyMap<string, number>>;
134}136}
135137
136interface StreamStatusOptions {138interface StreamStatusOptions {
...@@ -180,6 +182,7 @@ const normalizeCwd = (cwd: string) =>...@@ -180,6 +182,7 @@ const normalizeCwd = (cwd: string) =>
180182
181export const make = Effect.gen(function* () {183export const make = Effect.gen(function* () {
182 const workflow = yield* GitWorkflowService.GitWorkflowService;184 const workflow = yield* GitWorkflowService.GitWorkflowService;
185 const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;
183 const fs = yield* FileSystem.FileSystem;186 const fs = yield* FileSystem.FileSystem;
184 const changesPubSub = yield* Effect.acquireRelease(187 const changesPubSub = yield* Effect.acquireRelease(
185 PubSub.unbounded<VcsStatusChange>(),188 PubSub.unbounded<VcsStatusChange>(),
...@@ -378,6 +381,7 @@ export const make = Effect.gen(function* () {...@@ -378,6 +381,7 @@ export const make = Effect.gen(function* () {
378381
379 const makeRemoteRefreshLoop = (382 const makeRemoteRefreshLoop = (
380 cwd: string,383 cwd: string,
384 demandCwdsRef: Ref.Ref<ReadonlyMap<string, number>>,
381 automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>,385 automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>,
382 refreshImmediately: boolean,386 refreshImmediately: boolean,
383 ) => {387 ) => {
...@@ -394,6 +398,22 @@ export const make = Effect.gen(function* () {...@@ -394,6 +398,22 @@ export const make = Effect.gen(function* () {
394 return activeInterval;398 return activeInterval;
395 }399 }
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
397 const exit = yield* refreshRemoteStatus(cwd, {417 const exit = yield* refreshRemoteStatus(cwd, {
398 refreshUpstream: !Duration.isZero(configuredInterval),418 refreshUpstream: !Duration.isZero(configuredInterval),
399 }).pipe(Effect.exit);419 }).pipe(Effect.exit);
...@@ -444,55 +464,88 @@ export const make = Effect.gen(function* () {...@@ -444,55 +464,88 @@ export const make = Effect.gen(function* () {
444464
445 const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* (465 const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* (
446 cwd: string,466 cwd: string,
467 demandCwd: string,
447 automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>,468 automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>,
448 refreshImmediately: boolean,469 refreshImmediately: boolean,
449 ) {470 ) {
450 yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => {471 yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => {
451 const existing = activePollers.get(cwd);472 const existing = activePollers.get(cwd);
452 if (existing) {473 if (existing) {
453 const nextPollers = new Map(activePollers);474 return Ref.update(existing.demandCwds, (demandCwds) => {
454 nextPollers.set(cwd, {475 const next = new Map(demandCwds);
455 ...existing,476 next.set(demandCwd, (next.get(demandCwd) ?? 0) + 1);
456 subscriberCount: existing.subscriberCount + 1,477 return next;
457 });478 }).pipe(
458 return Effect.succeed([undefined, nextPollers] as const);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 );
459 }488 }
460489
461 return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval, refreshImmediately).pipe(490 return Ref.make<ReadonlyMap<string, number>>(new Map([[demandCwd, 1]])).pipe(
462 Effect.forkIn(broadcasterScope),491 Effect.flatMap((demandCwds) =>
463 Effect.map((fiber) => {492 makeRemoteRefreshLoop(
464 const nextPollers = new Map(activePollers);493 cwd,
465 nextPollers.set(cwd, {494 demandCwds,
466 fiber,495 automaticRemoteRefreshInterval,
467 subscriberCount: 1,496 refreshImmediately,
468 });497 ).pipe(
469 return [undefined, nextPollers] as const;498 Effect.forkIn(broadcasterScope),
470 }),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 ),
471 );510 );
472 });511 });
473 });512 });
474513
475 const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* (514 const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* (
476 cwd: string,515 cwd: string,
516 demandCwd: string,
477 ) {517 ) {
478 const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => {518 const pollerToInterrupt = yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => {
479 const existing = activePollers.get(cwd);519 const existing = activePollers.get(cwd);
480 if (!existing) {520 if (!existing) {
481 return [null, activePollers] as const;521 return Effect.succeed([null, activePollers] as const);
482 }522 }
483523
484 if (existing.subscriberCount > 1) {524 if (existing.subscriberCount > 1) {
485 const nextPollers = new Map(activePollers);525 return Ref.update(existing.demandCwds, (demandCwds) => {
486 nextPollers.set(cwd, {526 const nextDemandCwds = new Map(demandCwds);
487 ...existing,527 const count = nextDemandCwds.get(demandCwd) ?? 0;
488 subscriberCount: existing.subscriberCount - 1,528 if (count <= 1) {
489 });529 nextDemandCwds.delete(demandCwd);
490 return [null, nextPollers] as const;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 );
491 }543 }
492544
493 const nextPollers = new Map(activePollers);545 return Effect.succeed([
494 nextPollers.delete(cwd);546 existing.fiber,
495 return [existing.fiber, nextPollers] as const;547 new Map([...activePollers].filter(([activeCwd]) => activeCwd !== cwd)),
548 ] as const);
496 });549 });
497550
498 if (pollerToInterrupt) {551 if (pollerToInterrupt) {
...@@ -510,12 +563,13 @@ export const make = Effect.gen(function* () {...@@ -510,12 +563,13 @@ export const make = Effect.gen(function* () {
510 const initialRemote = cachedStatus?.remote?.value ?? null;563 const initialRemote = cachedStatus?.remote?.value ?? null;
511 yield* retainRemotePoller(564 yield* retainRemotePoller(
512 cwd,565 cwd,
566 input.cwd,
513 options?.automaticRemoteRefreshInterval ??567 options?.automaticRemoteRefreshInterval ??
514 Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL),568 Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL),
515 cachedStatus?.remote === null || cachedStatus?.remote === undefined,569 cachedStatus?.remote === null || cachedStatus?.remote === undefined,
516 );570 );
517571
518 const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid);572 const release = releaseRemotePoller(cwd, input.cwd).pipe(Effect.ignore, Effect.asVoid);
519573
520 return Stream.concat(574 return Stream.concat(
521 Stream.make({575 Stream.make({
apps/server/src/ws.ts+88-89
...@@ -11,12 +11,6 @@ import * as Schema from "effect/Schema";...@@ -11,12 +11,6 @@ import * as Schema from "effect/Schema";
11import * as Stream from "effect/Stream";11import * as Stream from "effect/Stream";
12import {12import {
13 DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL,13 DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL,
14 AuthOrchestrationOperateScope,
15 AuthOrchestrationReadScope,
16 AuthReviewWriteScope,
17 AuthRelayWriteScope,
18 AuthTerminalOperateScope,
19 AuthAccessReadScope,
20 AuthAccessStreamError,14 AuthAccessStreamError,
21 type AuthAccessStreamEvent,15 type AuthAccessStreamEvent,
22 type AuthEnvironmentScope,16 type AuthEnvironmentScope,
...@@ -50,6 +44,7 @@ import {...@@ -50,6 +44,7 @@ import {
50 FilesystemBrowseError,44 FilesystemBrowseError,
51 AssetWorkspaceContextNotFoundError,45 AssetWorkspaceContextNotFoundError,
52 AssetWorkspaceContextResolutionError,46 AssetWorkspaceContextResolutionError,
47 RpcClientId,
53 EnvironmentAuthorizationError,48 EnvironmentAuthorizationError,
54 ThreadId,49 ThreadId,
55 type TerminalAttachStreamEvent,50 type TerminalAttachStreamEvent,
...@@ -59,6 +54,7 @@ import {...@@ -59,6 +54,7 @@ import {
59 WS_METHODS,54 WS_METHODS,
60 WsRpcGroup,55 WsRpcGroup,
61} from "@t3tools/contracts";56} from "@t3tools/contracts";
57import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
62import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http";58import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http";
63import { RpcSerialization, RpcServer } from "effect/unstable/rpc";59import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
6460
...@@ -98,9 +94,12 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts";...@@ -98,9 +94,12 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts";
98import * as ReviewService from "./review/ReviewService.ts";94import * as ReviewService from "./review/ReviewService.ts";
99import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";95import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
100import * as ServerEnvironment from "./environment/ServerEnvironment.ts";96import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
97import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
101import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";98import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
99import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts";
102import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";100import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
103import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";101import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
102import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
104import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";103import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
105import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts";104import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts";
106import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";105import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
...@@ -295,78 +294,6 @@ const PROVIDER_STATUS_DEBOUNCE_MS = 200;...@@ -295,78 +294,6 @@ const PROVIDER_STATUS_DEBOUNCE_MS = 200;
295// Matches the event store's default page size (DEFAULT_READ_FROM_SEQUENCE_LIMIT).294// Matches the event store's default page size (DEFAULT_READ_FROM_SEQUENCE_LIMIT).
296const SHELL_RESUME_MAX_GAP = 1_000;295const 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
370function toAuthAccessStreamEvent(297function toAuthAccessStreamEvent(
371 change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange,298 change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange,
372 revision: number,299 revision: number,
...@@ -438,10 +365,28 @@ const makeWsRpcLayer = (...@@ -438,10 +365,28 @@ const makeWsRpcLayer = (
438 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;365 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
439 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;366 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
440 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;367 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 );
441 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;384 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
442 const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery;385 const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery;
443 const automaticGitFetchInterval = serverSettings.getSettings.pipe(386 const automaticGitFetchInterval = serverSettings.getSettings.pipe(
444 Effect.map((settings) => settings.automaticGitFetchInterval),387 Effect.map(
388 (settings) => resolveServerBackgroundActivitySettings(settings).automaticGitFetchInterval,
389 ),
445 Effect.catch((cause) =>390 Effect.catch((cause) =>
446 Effect.logWarning("Failed to read automatic Git fetch interval setting", {391 Effect.logWarning("Failed to read automatic Git fetch interval setting", {
447 detail: cause.message,392 detail: cause.message,
...@@ -454,6 +399,7 @@ const makeWsRpcLayer = (...@@ -454,6 +399,7 @@ const makeWsRpcLayer = (
454 const sessions = yield* SessionStore.SessionStore;399 const sessions = yield* SessionStore.SessionStore;
455 const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;400 const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;
456 const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor;401 const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor;
402 const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry;
457 const relayClient = yield* RelayClient.RelayClient;403 const relayClient = yield* RelayClient.RelayClient;
458 const authorizationError = (requiredScope: AuthEnvironmentScope) =>404 const authorizationError = (requiredScope: AuthEnvironmentScope) =>
459 new EnvironmentAuthorizationError({405 new EnvironmentAuthorizationError({
...@@ -474,13 +420,6 @@ const makeWsRpcLayer = (...@@ -474,13 +420,6 @@ const makeWsRpcLayer = (
474 currentSession.scopes.includes(requiredScope)420 currentSession.scopes.includes(requiredScope)
475 ? stream421 ? stream
476 : Stream.fail(authorizationError(requiredScope));422 : 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 };
484 const observeRpcEffect = <A, E, R>(423 const observeRpcEffect = <A, E, R>(
485 method: string,424 method: string,
486 effect: Effect.Effect<A, E, R>,425 effect: Effect.Effect<A, E, R>,
...@@ -488,7 +427,7 @@ const makeWsRpcLayer = (...@@ -488,7 +427,7 @@ const makeWsRpcLayer = (
488 ) =>427 ) =>
489 instrumentRpcEffect(428 instrumentRpcEffect(
490 method,429 method,
491 authorizeEffect(requiredScopeForMethod(method), effect),430 authorizeEffect(requiredScopeForRpcMethod(method), effect),
492 traceAttributes,431 traceAttributes,
493 );432 );
494 const observeRpcStream = <A, E, R>(433 const observeRpcStream = <A, E, R>(
...@@ -498,7 +437,7 @@ const makeWsRpcLayer = (...@@ -498,7 +437,7 @@ const makeWsRpcLayer = (
498 ) =>437 ) =>
499 instrumentRpcStream(438 instrumentRpcStream(
500 method,439 method,
501 authorizeStream(requiredScopeForMethod(method), stream),440 authorizeStream(requiredScopeForRpcMethod(method), stream),
502 traceAttributes,441 traceAttributes,
503 );442 );
504 const observeRpcStreamEffect = <A, StreamError, StreamContext, EffectError, EffectContext>(443 const observeRpcStreamEffect = <A, StreamError, StreamContext, EffectError, EffectContext>(
...@@ -512,7 +451,7 @@ const makeWsRpcLayer = (...@@ -512,7 +451,7 @@ const makeWsRpcLayer = (
512 ) =>451 ) =>
513 instrumentRpcStreamEffect(452 instrumentRpcStreamEffect(
514 method,453 method,
515 authorizeEffect(requiredScopeForMethod(method), effect),454 authorizeEffect(requiredScopeForRpcMethod(method), effect),
516 traceAttributes,455 traceAttributes,
517 );456 );
518 const toDispatchCommandError = (cause: unknown, fallbackMessage: string) =>457 const toDispatchCommandError = (cause: unknown, fallbackMessage: string) =>
...@@ -1494,10 +1433,50 @@ const makeWsRpcLayer = (...@@ -1494,10 +1433,50 @@ const makeWsRpcLayer = (
1494 "rpc.aggregate": "server",1433 "rpc.aggregate": "server",
1495 },1434 },
1496 ),1435 ),
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 }),
1497 [WS_METHODS.serverSignalProcess]: (input) =>1448 [WS_METHODS.serverSignalProcess]: (input) =>
1498 observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), {1449 observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), {
1499 "rpc.aggregate": "server",1450 "rpc.aggregate": "server",
1500 }),1451 }),
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 }),
1501 [WS_METHODS.cloudGetRelayClientStatus]: (_input) =>1480 [WS_METHODS.cloudGetRelayClientStatus]: (_input) =>
1502 observeRpcEffect(WS_METHODS.cloudGetRelayClientStatus, relayClient.resolve, {1481 observeRpcEffect(WS_METHODS.cloudGetRelayClientStatus, relayClient.resolve, {
1503 "rpc.aggregate": "cloud",1482 "rpc.aggregate": "cloud",
...@@ -2021,6 +2000,26 @@ const makeWsRpcLayer = (...@@ -2021,6 +2000,26 @@ const makeWsRpcLayer = (
2021 }),2000 }),
2022 { "rpc.aggregate": "auth" },2001 { "rpc.aggregate": "auth" },
2023 ),2002 ),
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 ),
2024 });2023 });
2025 }),2024 }),
2026 );2025 );
apps/web/src/components/settings/DiagnosticsSettings.tsx+10-3
...@@ -37,6 +37,7 @@ import { Button } from "../ui/button";...@@ -37,6 +37,7 @@ import { Button } from "../ui/button";
37import { ScrollArea } from "../ui/scroll-area";37import { ScrollArea } from "../ui/scroll-area";
38import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";38import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
39import { toastManager } from "../ui/toast";39import { toastManager } from "../ui/toast";
40import { ResourceTelemetryDiagnostics } from "./ResourceTelemetryDiagnostics";
40import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout";41import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout";
41import { useAtomCommand } from "../../state/use-atom-command";42import { useAtomCommand } from "../../state/use-atom-command";
4243
...@@ -904,12 +905,16 @@ export function DiagnosticsSettingsPanel() {...@@ -904,12 +905,16 @@ export function DiagnosticsSettingsPanel() {
904 if (environmentId === null) {905 if (environmentId === null) {
905 return;906 return;
906 }907 }
908 const process = processData?.processes.find((entry) => entry.pid === pid);
909 if (process === undefined) {
910 return;
911 }
907912
908 setSignalingPid(pid);913 setSignalingPid(pid);
909 void (async () => {914 void (async () => {
910 const result = await signalServerProcess({915 const result = await signalServerProcess({
911 environmentId,916 environmentId,
912 input: { pid, signal },917 input: { pid, startTimeMs: process.startTimeMs, signal },
913 });918 });
914 setSignalingPid(null);919 setSignalingPid(null);
915 if (result._tag === "Failure") {920 if (result._tag === "Failure") {
...@@ -946,7 +951,7 @@ export function DiagnosticsSettingsPanel() {...@@ -946,7 +951,7 @@ export function DiagnosticsSettingsPanel() {
946 refreshProcesses();951 refreshProcesses();
947 })();952 })();
948 },953 },
949 [environmentId, refreshProcesses, signalServerProcess],954 [environmentId, processData?.processes, refreshProcesses, signalServerProcess],
950 );955 );
951956
952 const processDiagnosticsError = processData ? Option.getOrNull(processData.error) : null;957 const processDiagnosticsError = processData ? Option.getOrNull(processData.error) : null;
...@@ -957,7 +962,9 @@ export function DiagnosticsSettingsPanel() {...@@ -957,7 +962,9 @@ export function DiagnosticsSettingsPanel() {
957 : false;962 : false;
958963
959 return (964 return (
960 <SettingsPageContainer>965 <SettingsPageContainer className="max-w-6xl gap-10">
966 <ResourceTelemetryDiagnostics />
967
961 <SettingsSection968 <SettingsSection
962 title="Live Processes"969 title="Live Processes"
963 headerAction={970 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 @@...@@ -1,17 +1,126 @@
1import {1import {
2 DEFAULT_SERVER_SETTINGS,2 DEFAULT_SERVER_SETTINGS,
3 DEFAULT_UNIFIED_SETTINGS,
3 ProviderDriverKind,4 ProviderDriverKind,
4 ProviderInstanceId,5 ProviderInstanceId,
5 type ProviderInstanceConfig,6 type ProviderInstanceConfig,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import { getBackgroundActivityPresetSettings } from "@t3tools/shared/backgroundActivitySettings";
9import * as Duration from "effect/Duration";
7import { describe, expect, it } from "vite-plus/test";10import { describe, expect, it } from "vite-plus/test";
8import {11import {
12 backgroundActivitySharedPolicySettings,
9 buildProviderInstanceUpdatePatch,13 buildProviderInstanceUpdatePatch,
10 formatDiagnosticsDescription,14 formatDiagnosticsDescription,
15 hasChangedBackgroundActivitySettings,
11 isProjectGroupingEnabled,16 isProjectGroupingEnabled,
12 projectGroupingModeFromToggle,17 projectGroupingModeFromToggle,
18 resolveBackgroundActivityProfileOption,
13} from "./SettingsPanels.logic";19} 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
15describe("project grouping toggle", () => {124describe("project grouping toggle", () => {
16 it("enables repository grouping and disables into separate projects", () => {125 it("enables repository grouping and disables into separate projects", () => {
17 expect(isProjectGroupingEnabled("repository")).toBe(true);126 expect(isProjectGroupingEnabled("repository")).toBe(true);
apps/web/src/components/settings/SettingsPanels.logic.ts+67
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1import type {1import type {
2 BackgroundActivityProfile,
3 BackgroundActivitySettings,
2 ProviderDriverKind,4 ProviderDriverKind,
3 ProviderInstanceConfig,5 ProviderInstanceConfig,
4 ProviderInstanceId,6 ProviderInstanceId,
...@@ -7,6 +9,12 @@ import type {...@@ -7,6 +9,12 @@ import type {
7 UnifiedSettings,9 UnifiedSettings,
8} from "@t3tools/contracts";10} from "@t3tools/contracts";
9import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";11import { 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
11export function isProjectGroupingEnabled(mode: SidebarProjectGroupingMode): boolean {19export function isProjectGroupingEnabled(mode: SidebarProjectGroupingMode): boolean {
12 return mode !== "separate";20 return mode !== "separate";
...@@ -41,6 +49,65 @@ export function rememberEnabledProjectGroupingMode(mode: SidebarProjectGroupingM...@@ -41,6 +49,65 @@ export function rememberEnabledProjectGroupingMode(mode: SidebarProjectGroupingM
41 }49 }
42}50}
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
44function collapseOtelSignalsUrl(input: {111function collapseOtelSignalsUrl(input: {
45 readonly tracesUrl: string;112 readonly tracesUrl: string;
46 readonly metricsUrl: string;113 readonly metricsUrl: string;
apps/web/src/components/settings/SettingsPanels.tsx+596-6
...@@ -1,10 +1,20 @@...@@ -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";
2import { Link } from "@tanstack/react-router";10import { Link } from "@tanstack/react-router";
3import type { CSSProperties } from "react";11import type { CSSProperties } from "react";
4import { useCallback, useMemo, useRef, useState } from "react";12import { useCallback, useMemo, useRef, useState } from "react";
5import { useAtomValue } from "@effect/atom-react";13import { useAtomValue } from "@effect/atom-react";
6import {14import {
7 defaultInstanceIdForDriver,15 defaultInstanceIdForDriver,
16 type BackgroundActivityProfile,
17 type BackgroundActivitySettings,
8 type DesktopUpdateChannel,18 type DesktopUpdateChannel,
9 PROVIDER_DISPLAY_NAMES,19 PROVIDER_DISPLAY_NAMES,
10 ProviderDriverKind,20 ProviderDriverKind,
...@@ -27,6 +37,11 @@ import {...@@ -27,6 +37,11 @@ import {
27 MAX_GLASS_OPACITY,37 MAX_GLASS_OPACITY,
28 MIN_GLASS_OPACITY,38 MIN_GLASS_OPACITY,
29} from "@t3tools/contracts/settings";39} from "@t3tools/contracts/settings";
40import {
41 getBackgroundActivityBaseProfile,
42 getBackgroundActivityPresetSettings,
43 resolveServerBackgroundActivitySettings,
44} from "@t3tools/shared/backgroundActivitySettings";
30import { createModelSelection } from "@t3tools/shared/model";45import { createModelSelection } from "@t3tools/shared/model";
31import * as Arr from "effect/Array";46import * as Arr from "effect/Array";
32import * as Duration from "effect/Duration";47import * as Duration from "effect/Duration";
...@@ -72,7 +87,23 @@ import { useProjects } from "../../state/entities";...@@ -72,7 +87,23 @@ import { useProjects } from "../../state/entities";
72import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";87import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
73import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat";88import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat";
74import { Button } from "../ui/button";89import { Button } from "../ui/button";
90import {
91 Dialog,
92 DialogDescription,
93 DialogFooter,
94 DialogHeader,
95 DialogPanel,
96 DialogPopup,
97 DialogTitle,
98} from "../ui/dialog";
75import { DraftInput } from "../ui/draft-input";99import { DraftInput } from "../ui/draft-input";
100import {
101 NumberField,
102 NumberFieldDecrement,
103 NumberFieldGroup,
104 NumberFieldIncrement,
105 NumberFieldInput,
106} from "../ui/number-field";
76import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";107import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
77import { Switch } from "../ui/switch";108import { Switch } from "../ui/switch";
78import { stackedThreadToast, toastManager } from "../ui/toast";109import { stackedThreadToast, toastManager } from "../ui/toast";
...@@ -88,12 +119,15 @@ import {...@@ -88,12 +119,15 @@ import {
88import { ProviderInstanceCard } from "./ProviderInstanceCard";119import { ProviderInstanceCard } from "./ProviderInstanceCard";
89import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta";120import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta";
90import {121import {
122 backgroundActivitySharedPolicySettings,
91 buildProviderInstanceUpdatePatch,123 buildProviderInstanceUpdatePatch,
92 formatDiagnosticsDescription,124 formatDiagnosticsDescription,
125 hasChangedBackgroundActivitySettings,
93 isProjectGroupingEnabled,126 isProjectGroupingEnabled,
94 projectGroupingModeFromToggle,127 projectGroupingModeFromToggle,
95 readLastEnabledProjectGroupingMode,128 readLastEnabledProjectGroupingMode,
96 rememberEnabledProjectGroupingMode,129 rememberEnabledProjectGroupingMode,
130 resolveBackgroundActivityProfileOption,
97} from "./SettingsPanels.logic";131} from "./SettingsPanels.logic";
98import {132import {
99 SettingResetButton,133 SettingResetButton,
...@@ -132,7 +166,129 @@ const TIMESTAMP_FORMAT_LABELS = {...@@ -132,7 +166,129 @@ const TIMESTAMP_FORMAT_LABELS = {
132 "24-hour": "24-hour",166 "24-hour": "24-hour",
133} as const;167} 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;
135const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex");198const 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
137function withoutProviderInstanceKey<V>(293function withoutProviderInstanceKey<V>(
138 record: Readonly<Record<ProviderInstanceId, V>> | undefined,294 record: Readonly<Record<ProviderInstanceId, V>> | undefined,
...@@ -408,6 +564,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -408,6 +564,7 @@ export function useSettingsRestore(onRestored?: () => void) {
408 settings.textGenerationModelSelection ?? null,564 settings.textGenerationModelSelection ?? null,
409 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,565 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
410 );566 );
567 const isBackgroundActivityDirty = hasChangedBackgroundActivitySettings(settings);
411568
412 const changedSettingLabels = useMemo(569 const changedSettingLabels = useMemo(
413 () => [570 () => [
...@@ -441,10 +598,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -441,10 +598,7 @@ export function useSettingsRestore(onRestored?: () => void) {
441 DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks598 DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks
442 ? ["Provider update checks"]599 ? ["Provider update checks"]
443 : []),600 : []),
444 ...(Duration.toMillis(settings.automaticGitFetchInterval) !==601 ...(isBackgroundActivityDirty ? ["Background activity"] : []),
445 Duration.toMillis(DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval)
446 ? ["Automatic Git fetch interval"]
447 : []),
448 ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode602 ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode
449 ? ["New thread mode"]603 ? ["New thread mode"]
450 : []),604 : []),
...@@ -465,6 +619,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -465,6 +619,7 @@ export function useSettingsRestore(onRestored?: () => void) {
465 ],619 ],
466 [620 [
467 isTextGenerationModelDirty,621 isTextGenerationModelDirty,
622 isBackgroundActivityDirty,
468 settings.autoOpenPlanSidebar,623 settings.autoOpenPlanSidebar,
469 settings.confirmThreadArchive,624 settings.confirmThreadArchive,
470 settings.confirmThreadDelete,625 settings.confirmThreadDelete,
...@@ -474,7 +629,6 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -474,7 +629,6 @@ export function useSettingsRestore(onRestored?: () => void) {
474 settings.diffIgnoreWhitespace,629 settings.diffIgnoreWhitespace,
475 settings.environmentIdentificationMode,630 settings.environmentIdentificationMode,
476 settings.glassOpacity,631 settings.glassOpacity,
477 settings.automaticGitFetchInterval,
478 settings.enableAssistantStreaming,632 settings.enableAssistantStreaming,
479 settings.enableProviderUpdateChecks,633 settings.enableProviderUpdateChecks,
480 settings.sidebarProjectGroupingMode,634 settings.sidebarProjectGroupingMode,
...@@ -507,7 +661,10 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -507,7 +661,10 @@ export function useSettingsRestore(onRestored?: () => void) {
507 autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar,661 autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar,
508 enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming,662 enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming,
509 enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,663 enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,
664 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
665 backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile,
510 automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,666 automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,
667 providerHealthRefreshInterval: DEFAULT_UNIFIED_SETTINGS.providerHealthRefreshInterval,
511 defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode,668 defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode,
512 newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin,669 newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin,
513 addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory,670 addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory,
...@@ -524,6 +681,272 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -524,6 +681,272 @@ export function useSettingsRestore(onRestored?: () => void) {
524 };681 };
525}682}
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
527export function AppearanceSettingsPanel() {950export function AppearanceSettingsPanel() {
528 const { theme, setTheme } = useTheme();951 const { theme, setTheme } = useTheme();
529 const settings = usePrimarySettings();952 const settings = usePrimarySettings();
...@@ -693,6 +1116,7 @@ export function AppearanceSettingsPanel() {...@@ -693,6 +1116,7 @@ export function AppearanceSettingsPanel() {
693export function GeneralSettingsPanel() {1116export function GeneralSettingsPanel() {
694 const settings = usePrimarySettings();1117 const settings = usePrimarySettings();
695 const updateSettings = useUpdatePrimarySettings();1118 const updateSettings = useUpdatePrimarySettings();
1119 const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false);
696 const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>(1120 const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>(
697 readLastEnabledProjectGroupingMode(),1121 readLastEnabledProjectGroupingMode(),
698 );1122 );
...@@ -728,6 +1152,19 @@ export function GeneralSettingsPanel() {...@@ -728,6 +1152,19 @@ export function GeneralSettingsPanel() {
728 settings.textGenerationModelSelection ?? null,1152 settings.textGenerationModelSelection ?? null,
729 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,1153 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
730 );1154 );
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
732 return (1169 return (
733 <SettingsPageContainer>1170 <SettingsPageContainer>
...@@ -890,6 +1327,88 @@ export function GeneralSettingsPanel() {...@@ -890,6 +1327,88 @@ export function GeneralSettingsPanel() {
890 }1327 }
891 />1328 />
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
893 <SettingsRow1412 <SettingsRow
894 title="Auto-open task panel"1413 title="Auto-open task panel"
895 description="Open the right-side plan and task panel automatically when steps appear."1414 description="Open the right-side plan and task panel automatically when steps appear."
...@@ -1207,6 +1726,14 @@ export function ProviderSettingsPanel() {...@@ -1207,6 +1726,14 @@ export function ProviderSettingsPanel() {
1207 );1726 );
1208 const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders);1727 const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders);
1209 const textGenInstanceId = textGenerationModelSelection.instanceId;1728 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);
1210 const lastCheckedAt =1737 const lastCheckedAt =
1211 serverProviders.length > 01738 serverProviders.length > 0
1212 ? serverProviders.reduce(1739 ? serverProviders.reduce(
...@@ -1508,6 +2035,69 @@ export function ProviderSettingsPanel() {...@@ -1508,6 +2035,69 @@ export function ProviderSettingsPanel() {
1508 </div>2035 </div>
1509 }2036 }
1510 >2037 >
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
1511 {rows.map((row) => {2101 {rows.map((row) => {
1512 const driverOption = getDriverOption(row.driver);2102 const driverOption = getDriverOption(row.driver);
1513 const liveProvider = serverProviders.find(2103 const liveProvider = serverProviders.find(
apps/web/src/components/settings/SourceControlSettings.tsx+79-13
...@@ -1,8 +1,9 @@...@@ -1,8 +1,9 @@
1import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react";1import { ChevronDownIcon, GitPullRequestIcon, InfoIcon, RefreshCwIcon } from "lucide-react";
2import * as Duration from "effect/Duration";2import * as Duration from "effect/Duration";
3import * as Option from "effect/Option";3import * as Option from "effect/Option";
4import { useState, type ReactNode } from "react";4import { useState, type ReactNode } from "react";
5import type {5import type {
6 BackgroundActivitySettings,
6 SourceControlProviderKind,7 SourceControlProviderKind,
7 SourceControlDiscoveryResult,8 SourceControlDiscoveryResult,
8 SourceControlProviderAuth,9 SourceControlProviderAuth,
...@@ -10,7 +11,11 @@ import type {...@@ -10,7 +11,11 @@ import type {
10 VcsDriverKind,11 VcsDriverKind,
11 VcsDiscoveryItem,12 VcsDiscoveryItem,
12} from "@t3tools/contracts";13} from "@t3tools/contracts";
13import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";14import {
15 getBackgroundActivityBaseProfile,
16 getBackgroundActivityPresetSettings,
17 resolveServerBackgroundActivitySettings,
18} from "@t3tools/shared/backgroundActivitySettings";
1419
15import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";20import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
16import { cn } from "../../lib/utils";21import { cn } from "../../lib/utils";
...@@ -70,6 +75,11 @@ const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = {...@@ -70,6 +75,11 @@ const VCS_ICONS: Partial<Record<VcsDriverKind, Icon>> = {
7075
71const SOURCE_CONTROL_SKELETON_ROWS = ["primary", "secondary"] as const;76const SOURCE_CONTROL_SKELETON_ROWS = ["primary", "secondary"] as const;
72const GIT_FETCH_INTERVAL_STEP_SECONDS = 5;77const GIT_FETCH_INTERVAL_STEP_SECONDS = 5;
78type BackgroundActivityOverridePatch = Partial<{
79 [K in keyof BackgroundActivitySettings["overrides"]]:
80 | BackgroundActivitySettings["overrides"][K]
81 | undefined;
82}>;
7383
74function durationToSeconds(duration: Duration.Duration): number {84function durationToSeconds(duration: Duration.Duration): number {
75 return Math.round(Duration.toMillis(duration) / 1_000);85 return Math.round(Duration.toMillis(duration) / 1_000);
...@@ -82,6 +92,50 @@ function normalizeFetchIntervalSeconds(value: number | null): number {...@@ -82,6 +92,50 @@ function normalizeFetchIntervalSeconds(value: number | null): number {
82 return Math.max(0, Math.round(value));92 return Math.max(0, Math.round(value));
83}93}
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
85function optionLabel(value: Option.Option<string>): string | null {139function optionLabel(value: Option.Option<string>): string | null {
86 return Option.getOrNull(value);140 return Option.getOrNull(value);
87}141}
...@@ -292,13 +346,16 @@ function DiscoveryItemRow({...@@ -292,13 +346,16 @@ function DiscoveryItemRow({
292}346}
293347
294function GitFetchIntervalSettings() {348function GitFetchIntervalSettings() {
295 const automaticGitFetchInterval = usePrimarySettings(349 const settings = usePrimarySettings();
296 (settings) => settings.automaticGitFetchInterval,
297 );
298 const updateSettings = useUpdatePrimarySettings();350 const updateSettings = useUpdatePrimarySettings();
299 const automaticGitFetchIntervalSeconds = durationToSeconds(automaticGitFetchInterval);351 const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);
352 const automaticGitFetchIntervalSeconds = durationToSeconds(
353 resolvedBackgroundActivity.automaticGitFetchInterval,
354 );
300 const defaultAutomaticGitFetchIntervalSeconds = durationToSeconds(355 const defaultAutomaticGitFetchIntervalSeconds = durationToSeconds(
301 DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,356 getBackgroundActivityPresetSettings(
357 getBackgroundActivityBaseProfile(settings.backgroundActivity),
358 ).automaticGitFetchInterval,
302 );359 );
303 const canResetFetchInterval =360 const canResetFetchInterval =
304 automaticGitFetchIntervalSeconds !== defaultAutomaticGitFetchIntervalSeconds;361 automaticGitFetchIntervalSeconds !== defaultAutomaticGitFetchIntervalSeconds;
...@@ -309,6 +366,11 @@ function GitFetchIntervalSettings() {...@@ -309,6 +366,11 @@ function GitFetchIntervalSettings() {
309 <div className="min-w-0 space-y-1">366 <div className="min-w-0 space-y-1">
310 <div className="flex min-w-0 items-center gap-1">367 <div className="flex min-w-0 items-center gap-1">
311 <span className="text-xs font-medium text-foreground">Fetch interval</span>368 <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>
312 <span374 <span
313 className={cn(375 className={cn(
314 "inline-flex size-5 shrink-0 items-center justify-center transition-opacity",376 "inline-flex size-5 shrink-0 items-center justify-center transition-opacity",
...@@ -320,9 +382,11 @@ function GitFetchIntervalSettings() {...@@ -320,9 +382,11 @@ function GitFetchIntervalSettings() {
320 <SettingResetButton382 <SettingResetButton
321 label="fetch interval"383 label="fetch interval"
322 onClick={() =>384 onClick={() =>
323 updateSettings({385 updateSettings(
324 automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,386 backgroundActivityOverrideSettings(settings.backgroundActivity, {
325 })387 automaticGitFetchInterval: undefined,
388 }),
389 )
326 }390 }
327 />391 />
328 ) : null}392 ) : null}
...@@ -341,9 +405,11 @@ function GitFetchIntervalSettings() {...@@ -341,9 +405,11 @@ function GitFetchIntervalSettings() {
341 size="sm"405 size="sm"
342 className="w-32"406 className="w-32"
343 onValueChange={(value) =>407 onValueChange={(value) =>
344 updateSettings({408 updateSettings(
345 automaticGitFetchInterval: Duration.seconds(normalizeFetchIntervalSeconds(value)),409 backgroundActivityOverrideSettings(settings.backgroundActivity, {
346 })410 automaticGitFetchInterval: Duration.seconds(normalizeFetchIntervalSeconds(value)),
411 }),
412 )
347 }413 }
348 >414 >
349 <NumberFieldGroup>415 <NumberFieldGroup>
apps/web/src/connection/runtime.ts+19-3
...@@ -5,6 +5,10 @@ import * as Layer from "effect/Layer";...@@ -5,6 +5,10 @@ import * as Layer from "effect/Layer";
5import { Atom } from "effect/unstable/reactivity";5import { Atom } from "effect/unstable/reactivity";
66
7import { runtimeContextLayer } from "../lib/runtime";7import { runtimeContextLayer } from "../lib/runtime";
8import {
9 backgroundActivityObserverLayer,
10 backgroundActivityReporterLayer,
11} from "../lib/backgroundActivityReporter";
8import { connectionPlatformLayer } from "./platform";12import { connectionPlatformLayer } from "./platform";
913
10const providedConnectionPlatformLayer = connectionPlatformLayer.pipe(14const providedConnectionPlatformLayer = connectionPlatformLayer.pipe(
...@@ -17,10 +21,22 @@ type ConnectionLayerSource =...@@ -17,10 +21,22 @@ type ConnectionLayerSource =
17 | typeof Connection.layer21 | typeof Connection.layer
18 | typeof snapshotLoaderLayer22 | typeof snapshotLoaderLayer
19 | typeof runtimeContextLayer23 | typeof runtimeContextLayer
20 | typeof connectionPlatformLayer;24 | typeof connectionPlatformLayer
25 | typeof backgroundActivityObserverLayer
26 | typeof backgroundActivityReporterLayer;
2127
22const connectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe(28const providedClientConnectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe(
23 Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)),29 Layer.provideMerge(
30 Layer.mergeAll(
31 runtimeContextLayer,
32 providedConnectionPlatformLayer,
33 backgroundActivityObserverLayer,
34 ),
35 ),
36);
37
38const connectionLayer = backgroundActivityReporterLayer.pipe(
39 Layer.provideMerge(providedClientConnectionLayer),
24);40);
2541
26export const connectionAtomRuntime: Atom.AtomRuntime<42export const connectionAtomRuntime: Atom.AtomRuntime<
apps/web/src/env.ts+2-4
...@@ -1,8 +1,6 @@...@@ -1,8 +1,6 @@
1/**1/**
2 * True when running inside the Electron preload bridge, false in a regular browser.2 * 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-app3 * The preload script sets window.desktopBridge via contextBridge before any web-app
4 * code executes, so this is reliable at module load time.4 * code executes, so this is reliable at module load time.
5 */5 */
6export const isElectron =6export const isElectron = typeof window !== "undefined" && window.desktopBridge !== undefined;
7 typeof window !== "undefined" &&
8 (window.desktopBridge !== undefined || window.nativeApi !== undefined);
apps/web/src/environments/primary/httpLayer.ts-1
...@@ -10,7 +10,6 @@ function isSameOriginBrowserPrimary(): boolean {...@@ -10,7 +10,6 @@ function isSameOriginBrowserPrimary(): boolean {
10 if (10 if (
11 typeof window === "undefined" ||11 typeof window === "undefined" ||
12 window.desktopBridge !== undefined ||12 window.desktopBridge !== undefined ||
13 window.nativeApi !== undefined ||
14 !window.location.origin.startsWith("http")13 !window.location.origin.startsWith("http")
15 ) {14 ) {
16 return false;15 return false;
apps/web/src/hooks/useSettings.ts-1
...@@ -302,7 +302,6 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {...@@ -302,7 +302,6 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
302 });302 });
303 }303 }
304 }304 }
305
306 if (Object.keys(clientPatch).length > 0) {305 if (Object.keys(clientPatch).length > 0) {
307 persistClientSettings({306 persistClientSettings({
308 ...getClientSettingsSnapshot(),307 ...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(() => {...@@ -49,7 +49,6 @@ beforeEach(() => {
49 });49 });
50 }50 }
51 Reflect.deleteProperty(testWindow(), "desktopBridge");51 Reflect.deleteProperty(testWindow(), "desktopBridge");
52 Reflect.deleteProperty(testWindow(), "nativeApi");
53 Object.defineProperty(testWindow(), "localStorage", {52 Object.defineProperty(testWindow(), "localStorage", {
54 configurable: true,53 configurable: true,
55 value: createLocalStorageStub(),54 value: createLocalStorageStub(),
...@@ -61,16 +60,12 @@ afterEach(() => {...@@ -61,16 +60,12 @@ afterEach(() => {
61});60});
6261
63describe("LocalApi", () => {62describe("LocalApi", () => {
64 it("keeps backend operations unavailable in the browser facade", async () => {63 it("keeps backend operations out of the local host facade", async () => {
65 const { createLocalApi } = await import("./localApi");64 const { createLocalApi } = await import("./localApi");
66 const api = createLocalApi();65 const api = createLocalApi();
6766
68 await expect(api.server.getConfig()).rejects.toThrow(67 expect(api).not.toHaveProperty("server");
69 "Local backend API is unavailable before a backend is paired.",68 expect(api.shell).not.toHaveProperty("openInEditor");
70 );
71 await expect(api.shell.openInEditor("/tmp", "cursor")).rejects.toThrow(
72 "Local backend API is unavailable before a backend is paired.",
73 );
74 });69 });
7570
76 it("uses the browser context-menu fallback without a desktop bridge", async () => {71 it("uses the browser context-menu fallback without a desktop bridge", async () => {
...@@ -120,12 +115,4 @@ describe("LocalApi", () => {...@@ -120,12 +115,4 @@ describe("LocalApi", () => {
120 await api.persistence.setClientSettings(settings);115 await api.persistence.setClientSettings(settings);
121 await expect(api.persistence.getClientSettings()).resolves.toEqual(settings);116 await expect(api.persistence.getClientSettings()).resolves.toEqual(settings);
122 });117 });
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 });
131});118});
apps/web/src/localApi.ts+1-25
...@@ -6,10 +6,6 @@ import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientP...@@ -6,10 +6,6 @@ import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientP
66
7let cachedApi: LocalApi | undefined;7let cachedApi: LocalApi | undefined;
88
9function unavailableLocalBackendError(): Error {
10 return new Error("Local backend API is unavailable before a backend is paired.");
11}
12
13function createBrowserLocalApi(): LocalApi {9function createBrowserLocalApi(): LocalApi {
14 return {10 return {
15 dialogs: {11 dialogs: {
...@@ -25,7 +21,6 @@ function createBrowserLocalApi(): LocalApi {...@@ -25,7 +21,6 @@ function createBrowserLocalApi(): LocalApi {
25 },21 },
26 },22 },
27 shell: {23 shell: {
28 openInEditor: () => Promise.reject(unavailableLocalBackendError()),
29 openExternal: async (url) => {24 openExternal: async (url) => {
30 if (window.desktopBridge) {25 if (window.desktopBridge) {
31 const opened = await window.desktopBridge.openExternal(url);26 const opened = await window.desktopBridge.openExternal(url);
...@@ -63,20 +58,6 @@ function createBrowserLocalApi(): LocalApi {...@@ -63,20 +58,6 @@ function createBrowserLocalApi(): LocalApi {
63 writeBrowserClientSettings(settings);58 writeBrowserClientSettings(settings);
64 },59 },
65 },60 },
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 },
80 };61 };
81}62}
8263
...@@ -88,12 +69,7 @@ export function readLocalApi(): LocalApi | undefined {...@@ -88,12 +69,7 @@ export function readLocalApi(): LocalApi | undefined {
88 if (typeof window === "undefined") return undefined;69 if (typeof window === "undefined") return undefined;
89 if (cachedApi) return cachedApi;70 if (cachedApi) return cachedApi;
9071
91 if (window.nativeApi) {72 cachedApi = createLocalApi();
92 cachedApi = window.nativeApi;
93 return cachedApi;
94 }
95
96 cachedApi = createBrowserLocalApi();
97 return cachedApi;73 return cachedApi;
98}74}
9975
apps/web/src/vite-env.d.ts+1-2
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1/// <reference types="vite-plus/client" />1/// <reference types="vite-plus/client" />
22
3import type { DesktopBridge, LocalApi } from "@t3tools/contracts";3import type { DesktopBridge } from "@t3tools/contracts";
44
5interface ImportMetaEnv {5interface ImportMetaEnv {
6 readonly VITE_HTTP_URL: string;6 readonly VITE_HTTP_URL: string;
...@@ -22,7 +22,6 @@ interface ImportMeta {...@@ -22,7 +22,6 @@ interface ImportMeta {
2222
23declare global {23declare global {
24 interface Window {24 interface Window {
25 nativeApi?: LocalApi;
26 desktopBridge?: DesktopBridge;25 desktopBridge?: DesktopBridge;
27 }26 }
28}27}
docs/architecture/overview.md+4
...@@ -39,6 +39,10 @@ T3 Code runs as a **Node.js WebSocket server** that wraps `codex app-server` (JS...@@ -39,6 +39,10 @@ T3 Code runs as a **Node.js WebSocket server** that wraps `codex app-server` (JS
3939
40- **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).40- **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
42## Event Lifecycle46## Event Lifecycle
4347
44### Startup and client connect48### 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 @@...@@ -20,11 +20,13 @@
20 "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build",20 "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build",
21 "build:marketing": "vp run --filter @t3tools/marketing build",21 "build:marketing": "vp run --filter @t3tools/marketing build",
22 "build:desktop": "vp run --filter @t3tools/desktop --filter t3 build",22 "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",
23 "typecheck": "vp run -r --concurrency-limit 2 typecheck",24 "typecheck": "vp run -r --concurrency-limit 2 typecheck",
24 "tc": "vp run -r --concurrency-limit 2 typecheck",25 "tc": "vp run -r --concurrency-limit 2 typecheck",
25 "lint": "vp lint --report-unused-disable-directives",26 "lint": "vp lint --report-unused-disable-directives",
26 "lint:mobile": "node scripts/mobile-native-static-check.ts",27 "lint:mobile": "node scripts/mobile-native-static-check.ts",
27 "test": "vp run -r test",28 "test": "vp run -r test",
29 "test:resource-monitor": "cargo test --locked --manifest-path native/resource-monitor/Cargo.toml",
28 "test:desktop-smoke": "vp run --filter @t3tools/desktop smoke-test",30 "test:desktop-smoke": "vp run --filter @t3tools/desktop smoke-test",
29 "fmt": "vp fmt",31 "fmt": "vp fmt",
30 "fmt:check": "vp fmt --check",32 "fmt:check": "vp fmt --check",
packages/client-runtime/src/rpc/client.ts+106-87
...@@ -49,6 +49,7 @@ export type EnvironmentSubscriptionRpcTag =...@@ -49,6 +49,7 @@ export type EnvironmentSubscriptionRpcTag =
49 | typeof WS_METHODS.subscribeTerminalMetadata49 | typeof WS_METHODS.subscribeTerminalMetadata
50 | typeof WS_METHODS.subscribePreviewEvents50 | typeof WS_METHODS.subscribePreviewEvents
51 | typeof WS_METHODS.subscribeDiscoveredLocalServers51 | typeof WS_METHODS.subscribeDiscoveredLocalServers
52 | typeof WS_METHODS.subscribeResourceTelemetry
52 | typeof WS_METHODS.previewAutomationConnect53 | typeof WS_METHODS.previewAutomationConnect
53 | typeof WS_METHODS.subscribeVcsStatus54 | typeof WS_METHODS.subscribeVcsStatus
54 | typeof WS_METHODS.terminalAttach;55 | typeof WS_METHODS.terminalAttach;
...@@ -62,6 +63,23 @@ export type EnvironmentStreamRpcTag =...@@ -62,6 +63,23 @@ export type EnvironmentStreamRpcTag =
62 | EnvironmentStreamCommandRpcTag;63 | EnvironmentStreamCommandRpcTag;
6364
64export type EnvironmentUnaryRpcTag = Exclude<EnvironmentRpcTag, EnvironmentStreamRpcTag>;65export 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
65const isRpcClientError = Schema.is(RpcClientError.RpcClientError);83const isRpcClientError = Schema.is(RpcClientError.RpcClientError);
6684
67export type EnvironmentRpcInput<TTag extends EnvironmentRpcTag> = Parameters<RpcMethod<TTag>>[0];85export type EnvironmentRpcInput<TTag extends EnvironmentRpcTag> = Parameters<RpcMethod<TTag>>[0];
...@@ -166,93 +184,94 @@ export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>(...@@ -166,93 +184,94 @@ export function subscribeDynamic<TTag extends EnvironmentSubscriptionRpcTag>(
166 EnvironmentSupervisor184 EnvironmentSupervisor
167> {185> {
168 return Stream.unwrap(186 return Stream.unwrap(
169 EnvironmentSupervisor.pipe(187 Effect.gen(function* () {
170 Effect.map((supervisor) => {188 const supervisor = yield* EnvironmentSupervisor;
171 const sessionChanges = SubscriptionRef.changes(supervisor.session);189 const observer = yield* EnvironmentRpcSubscriptionObserver;
172 const sessions =190 const sessionChanges = SubscriptionRef.changes(supervisor.session);
173 options?.resubscribe === undefined191 const sessions =
174 ? sessionChanges192 options?.resubscribe === undefined
175 : Stream.merge(193 ? sessionChanges
176 sessionChanges,194 : Stream.merge(
177 options.resubscribe.pipe(195 sessionChanges,
178 Stream.mapEffect(() => SubscriptionRef.get(supervisor.session)),196 options.resubscribe.pipe(
179 ),197 Stream.mapEffect(() => SubscriptionRef.get(supervisor.session)),
180 );198 ),
181 return sessions.pipe(199 );
182 Stream.switchMap(200 return sessions.pipe(
183 Option.match({201 Stream.switchMap(
184 onNone: () => Stream.empty,202 Option.match({
185 onSome: (session) => {203 onNone: () => Stream.empty,
186 const method = session.client[tag] as (204 onSome: (session) => {
187 input: EnvironmentRpcInput<TTag>,205 const method = session.client[tag] as (
188 ) => Stream.Stream<206 input: EnvironmentRpcInput<TTag>,
189 EnvironmentRpcStreamValue<TTag>,207 ) => Stream.Stream<
190 EnvironmentRpcStreamFailure<TTag>208 EnvironmentRpcStreamValue<TTag>,
191 >;209 EnvironmentRpcStreamFailure<TTag>
192 const subscribeToSession = (): Stream.Stream<210 >;
193 EnvironmentRpcStreamValue<TTag>,211 const subscribeToSession = (): Stream.Stream<
194 EnvironmentRpcStreamFailure<TTag>212 EnvironmentRpcStreamValue<TTag>,
195 > =>213 EnvironmentRpcStreamFailure<TTag>
196 Stream.suspend(() =>214 > =>
197 Stream.unwrap(215 Stream.suspend(() =>
198 makeInput(session).pipe(216 Stream.unwrap(
199 Effect.map((input) =>217 Effect.gen(function* () {
200 method(input).pipe(218 const input = yield* makeInput(session);
201 Stream.catchCause((cause) => {219 const completeObservation = yield* observer.observe({
202 const hasOnlyExpectedFailures =220 environmentId: supervisor.target.environmentId,
203 cause.reasons.length > 0 &&221 method: tag,
204 cause.reasons.every((reason) => reason._tag === "Fail");222 input,
205 const isTransportFailure =223 });
206 hasOnlyExpectedFailures &&224 return method(input).pipe(
207 cause.reasons.every(225 Stream.ensuring(completeObservation),
208 (reason) =>226 Stream.catchCause((cause) => {
209 reason._tag === "Fail" && isRpcClientError(reason.error),227 const hasOnlyExpectedFailures =
210 );228 cause.reasons.length > 0 &&
211 if (isTransportFailure) {229 cause.reasons.every((reason) => reason._tag === "Fail");
212 return Stream.fromEffect(230 const isTransportFailure =
213 Effect.logWarning(231 hasOnlyExpectedFailures &&
214 "Durable RPC subscription lost its transport; waiting for the next session.",232 cause.reasons.every(
215 {233 (reason) => reason._tag === "Fail" && isRpcClientError(reason.error),
216 cause: Cause.pretty(cause),234 );
217 method: tag,235 if (isTransportFailure) {
218 environmentId: supervisor.target.environmentId,236 return Stream.fromEffect(
219 },237 Effect.logWarning(
220 ),238 "Durable RPC subscription lost its transport; waiting for the next session.",
221 ).pipe(Stream.drain);239 {
222 }240 cause: Cause.pretty(cause),
223 if (241 method: tag,
224 hasOnlyExpectedFailures &&242 environmentId: supervisor.target.environmentId,
225 options?.onExpectedFailure !== undefined243 },
226 ) {244 ),
227 const handled = Stream.fromEffect(245 ).pipe(Stream.drain);
228 options.onExpectedFailure(cause),246 }
229 ).pipe(Stream.drain);247 if (hasOnlyExpectedFailures && options?.onExpectedFailure !== undefined) {
230 if (options.retryExpectedFailureAfter === undefined) {248 const handled = Stream.fromEffect(
231 return handled;249 options.onExpectedFailure(cause),
232 }250 ).pipe(Stream.drain);
233 return handled.pipe(251 if (options.retryExpectedFailureAfter === undefined) {
234 Stream.concat(252 return handled;
235 Stream.fromEffect(253 }
236 Effect.sleep(options.retryExpectedFailureAfter),254 return handled.pipe(
237 ).pipe(Stream.drain),255 Stream.concat(
238 ),256 Stream.fromEffect(
239 Stream.concat(subscribeToSession()),257 Effect.sleep(options.retryExpectedFailureAfter),
240 );258 ).pipe(Stream.drain),
241 }259 ),
242 return Stream.failCause(cause);260 Stream.concat(subscribeToSession()),
243 }),261 );
244 ),262 }
245 ),263 return Stream.failCause(cause);
246 ),264 }),
247 ),265 );
248 );266 }),
249 return subscribeToSession();267 ),
250 },268 );
251 }),269 return subscribeToSession();
252 ),270 },
253 );271 }),
254 }),272 ),
255 ),273 );
274 }),
256 ).pipe(275 ).pipe(
257 Stream.withSpan("EnvironmentRpc.subscribe", {276 Stream.withSpan("EnvironmentRpc.subscribe", {
258 attributes: { "rpc.method": tag },277 attributes: { "rpc.method": tag },
packages/client-runtime/src/state/server.ts+18
...@@ -298,6 +298,16 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -298,6 +298,16 @@ export function createServerEnvironmentAtoms<R, E>(
298 label: "environment-data:server:process-resource-history",298 label: "environment-data:server:process-resource-history",
299 tag: WS_METHODS.serverGetProcessResourceHistory,299 tag: WS_METHODS.serverGetProcessResourceHistory,
300 }),300 }),
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 }),
301 configProjection,311 configProjection,
302 welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, {312 welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
303 label: "environment-data:server:welcome",313 label: "environment-data:server:welcome",
...@@ -349,5 +359,13 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -349,5 +359,13 @@ export function createServerEnvironmentAtoms<R, E>(
349 label: "environment-data:server:signal-process",359 label: "environment-data:server:signal-process",
350 tag: WS_METHODS.serverSignalProcess,360 tag: WS_METHODS.serverSignalProcess,
351 }),361 }),
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 }),
352 };370 };
353}371}
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");...@@ -43,6 +43,8 @@ export const TurnId = makeEntityId("TurnId");
43export type TurnId = typeof TurnId.Type;43export type TurnId = typeof TurnId.Type;
44export const AuthSessionId = makeEntityId("AuthSessionId");44export const AuthSessionId = makeEntityId("AuthSessionId");
45export type AuthSessionId = typeof AuthSessionId.Type;45export type AuthSessionId = typeof AuthSessionId.Type;
46export const RpcClientId = NonNegativeInt.pipe(Schema.brand("RpcClientId"));
47export type RpcClientId = typeof RpcClientId.Type;
4648
47export const ProviderItemId = makeEntityId("ProviderItemId");49export const ProviderItemId = makeEntityId("ProviderItemId");
48export type ProviderItemId = typeof ProviderItemId.Type;50export type ProviderItemId = typeof ProviderItemId.Type;
packages/contracts/src/desktopBootstrap.ts+4-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
22
3import { PortSchema } from "./baseSchemas.ts";3import { PortSchema, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
44
5export const DesktopBackendBootstrap = Schema.Struct({5export const DesktopBackendBootstrap = Schema.Struct({
6 mode: Schema.Literal("desktop"),6 mode: Schema.Literal("desktop"),
...@@ -16,6 +16,9 @@ export const DesktopBackendBootstrap = Schema.Struct({...@@ -16,6 +16,9 @@ export const DesktopBackendBootstrap = Schema.Struct({
16 tailscaleServePort: PortSchema,16 tailscaleServePort: PortSchema,
17 otlpTracesUrl: Schema.optional(Schema.String),17 otlpTracesUrl: Schema.optional(Schema.String),
18 otlpMetricsUrl: Schema.optional(Schema.String),18 otlpMetricsUrl: Schema.optional(Schema.String),
19 desktopTelemetryFd: Schema.optionalKey(PositiveInt),
20 desktopTelemetryControlFd: Schema.optionalKey(PositiveInt),
21 resourceMonitorPath: Schema.optionalKey(TrimmedNonEmptyString),
19});22});
2023
21export type DesktopBackendBootstrap = typeof DesktopBackendBootstrap.Type;24export type DesktopBackendBootstrap = typeof DesktopBackendBootstrap.Type;
packages/contracts/src/index.ts+2
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1export * from "./baseSchemas.ts";1export * from "./baseSchemas.ts";
2export * from "./background.ts";
2export * from "./auth.ts";3export * from "./auth.ts";
3export * from "./environment.ts";4export * from "./environment.ts";
4export * from "./environmentHttp.ts";5export * from "./environmentHttp.ts";
...@@ -26,4 +27,5 @@ export * from "./assets.ts";...@@ -26,4 +27,5 @@ export * from "./assets.ts";
26export * from "./review.ts";27export * from "./review.ts";
27export * from "./preview.ts";28export * from "./preview.ts";
28export * from "./previewAutomation.ts";29export * from "./previewAutomation.ts";
30export * from "./resourceTelemetry.ts";
29export * from "./rpc.ts";31export * from "./rpc.ts";
packages/contracts/src/ipc.ts+2-43
...@@ -31,20 +31,6 @@ import type {...@@ -31,20 +31,6 @@ import type {
31 ProjectWriteFileInput,31 ProjectWriteFileInput,
32 ProjectWriteFileResult,32 ProjectWriteFileResult,
33} from "./project.ts";33} 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";
48import type {34import type {
49 TerminalAttachInput,35 TerminalAttachInput,
50 TerminalAttachStreamEvent,36 TerminalAttachStreamEvent,
...@@ -57,7 +43,6 @@ import type {...@@ -57,7 +43,6 @@ import type {
57 TerminalSessionSnapshot,43 TerminalSessionSnapshot,
58 TerminalWriteInput,44 TerminalWriteInput,
59} from "./terminal.ts";45} from "./terminal.ts";
60import type { ServerRemoveKeybindingInput, ServerUpsertKeybindingInput } from "./server.ts";
61import * as Schema from "effect/Schema";46import * as Schema from "effect/Schema";
62import type {47import type {
63 DiscoveredLocalServerList,48 DiscoveredLocalServerList,
...@@ -100,13 +85,11 @@ import type {...@@ -100,13 +85,11 @@ import type {
100import { EnvironmentId } from "./baseSchemas.ts";85import { EnvironmentId } from "./baseSchemas.ts";
101import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts";86import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts";
102import { AdvertisedEndpoint } from "./remoteAccess.ts";87import { AdvertisedEndpoint } from "./remoteAccess.ts";
103import { EditorId } from "./editor.ts";
104import { ExecutionEnvironmentDescriptor } from "./environment.ts";88import { ExecutionEnvironmentDescriptor } from "./environment.ts";
105import type { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings.ts";89import type { ClientSettings } from "./settings.ts";
106import type {90import type {
107 SourceControlCloneRepositoryInput,91 SourceControlCloneRepositoryInput,
108 SourceControlCloneRepositoryResult,92 SourceControlCloneRepositoryResult,
109 SourceControlDiscoveryResult,
110 SourceControlPublishRepositoryInput,93 SourceControlPublishRepositoryInput,
111 SourceControlPublishRepositoryResult,94 SourceControlPublishRepositoryResult,
112 SourceControlRepositoryInfo,95 SourceControlRepositoryInfo,
...@@ -1115,7 +1098,7 @@ export interface DesktopPreviewBridge {...@@ -1115,7 +1098,7 @@ export interface DesktopPreviewBridge {
1115 * APIs bound to the local app shell, not to any particular backend environment.1098 * APIs bound to the local app shell, not to any particular backend environment.
1116 *1099 *
1117 * These capabilities describe the desktop/browser host that the user is1100 * These capabilities describe the desktop/browser host that the user is
1118 * currently running: dialogs, editor/external-link opening, context menus, and1101 * currently running: dialogs, external-link opening, context menus, and
1119 * app-level settings/config access. They must not be used as a proxy for1102 * app-level settings/config access. They must not be used as a proxy for
1120 * "whatever environment the user is targeting", because in a multi-environment1103 * "whatever environment the user is targeting", because in a multi-environment
1121 * world the local shell and a selected backend environment are distinct1104 * world the local shell and a selected backend environment are distinct
...@@ -1127,7 +1110,6 @@ export interface LocalApi {...@@ -1127,7 +1110,6 @@ export interface LocalApi {
1127 confirm: (message: string) => Promise<boolean>;1110 confirm: (message: string) => Promise<boolean>;
1128 };1111 };
1129 shell: {1112 shell: {
1130 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;
1131 openExternal: (url: string) => Promise<void>;1113 openExternal: (url: string) => Promise<void>;
1132 };1114 };
1133 contextMenu: {1115 contextMenu: {
...@@ -1140,29 +1122,6 @@ export interface LocalApi {...@@ -1140,29 +1122,6 @@ export interface LocalApi {
1140 getClientSettings: () => Promise<ClientSettings | null>;1122 getClientSettings: () => Promise<ClientSettings | null>;
1141 setClientSettings: (settings: ClientSettings) => Promise<void>;1123 setClientSettings: (settings: ClientSettings) => Promise<void>;
1142 };1124 };
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 };
1166}1125}
11671126
1168/**1127/**
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 {...@@ -8,6 +8,11 @@ import {
8 AuthAccessStreamEvent,8 AuthAccessStreamEvent,
9 EnvironmentAuthorizationError,9 EnvironmentAuthorizationError,
10} from "./auth.ts";10} from "./auth.ts";
11import {
12 BackgroundPolicySnapshot,
13 ClientActivityReportInput,
14 HostPowerSnapshot,
15} from "./background.ts";
11import {16import {
12 FilesystemBrowseInput,17 FilesystemBrowseInput,
13 FilesystemBrowseResult,18 FilesystemBrowseResult,
...@@ -132,6 +137,12 @@ import {...@@ -132,6 +137,12 @@ import {
132 ServerUpsertKeybindingInput,137 ServerUpsertKeybindingInput,
133 ServerUpsertKeybindingResult,138 ServerUpsertKeybindingResult,
134} from "./server.ts";139} from "./server.ts";
140import {
141 ResourceTelemetryHistory,
142 ResourceTelemetryHistoryInput,
143 ResourceTelemetryRetryResult,
144 ResourceTelemetrySnapshot,
145} from "./resourceTelemetry.ts";
135import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts";146import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts";
136import {147import {
137 SourceControlCloneRepositoryInput,148 SourceControlCloneRepositoryInput,
...@@ -215,7 +226,12 @@ export const WS_METHODS = {...@@ -215,7 +226,12 @@ export const WS_METHODS = {
215 serverGetTraceDiagnostics: "server.getTraceDiagnostics",226 serverGetTraceDiagnostics: "server.getTraceDiagnostics",
216 serverGetProcessDiagnostics: "server.getProcessDiagnostics",227 serverGetProcessDiagnostics: "server.getProcessDiagnostics",
217 serverGetProcessResourceHistory: "server.getProcessResourceHistory",228 serverGetProcessResourceHistory: "server.getProcessResourceHistory",
229 serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory",
230 serverRetryResourceTelemetry: "server.retryResourceTelemetry",
218 serverSignalProcess: "server.signalProcess",231 serverSignalProcess: "server.signalProcess",
232 serverReportClientActivity: "server.reportClientActivity",
233 serverReportHostPowerState: "server.reportHostPowerState",
234 serverGetBackgroundPolicy: "server.getBackgroundPolicy",
219235
220 // Cloud environment methods236 // Cloud environment methods
221 cloudGetRelayClientStatus: "cloud.getRelayClientStatus",237 cloudGetRelayClientStatus: "cloud.getRelayClientStatus",
...@@ -235,6 +251,8 @@ export const WS_METHODS = {...@@ -235,6 +251,8 @@ export const WS_METHODS = {
235 subscribeServerConfig: "subscribeServerConfig",251 subscribeServerConfig: "subscribeServerConfig",
236 subscribeServerLifecycle: "subscribeServerLifecycle",252 subscribeServerLifecycle: "subscribeServerLifecycle",
237 subscribeAuthAccess: "subscribeAuthAccess",253 subscribeAuthAccess: "subscribeAuthAccess",
254 subscribeBackgroundPolicy: "subscribeBackgroundPolicy",
255 subscribeResourceTelemetry: "subscribeResourceTelemetry",
238} as const;256} as const;
239257
240export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, {258export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, {
...@@ -326,6 +344,21 @@ export const WsServerGetProcessResourceHistoryRpc = Rpc.make(...@@ -326,6 +344,21 @@ export const WsServerGetProcessResourceHistoryRpc = Rpc.make(
326 },344 },
327);345);
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
329export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, {362export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, {
330 payload: ServerSignalProcessInput,363 payload: ServerSignalProcessInput,
331 success: ServerSignalProcessResult,364 success: ServerSignalProcessResult,
...@@ -345,6 +378,22 @@ export const WsCloudInstallRelayClientRpc = Rpc.make(WS_METHODS.cloudInstallRela...@@ -345,6 +378,22 @@ export const WsCloudInstallRelayClientRpc = Rpc.make(WS_METHODS.cloudInstallRela
345 stream: true,378 stream: true,
346});379});
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
348export const WsSourceControlLookupRepositoryRpc = Rpc.make(397export const WsSourceControlLookupRepositoryRpc = Rpc.make(
349 WS_METHODS.sourceControlLookupRepository,398 WS_METHODS.sourceControlLookupRepository,
350 {399 {
...@@ -690,6 +739,20 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess,...@@ -690,6 +739,20 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess,
690 stream: true,739 stream: true,
691});740});
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
693export const WsRpcGroup = RpcGroup.make(756export const WsRpcGroup = RpcGroup.make(
694 WsServerProbeRpc,757 WsServerProbeRpc,
695 WsServerGetConfigRpc,758 WsServerGetConfigRpc,
...@@ -704,7 +767,12 @@ export const WsRpcGroup = RpcGroup.make(...@@ -704,7 +767,12 @@ export const WsRpcGroup = RpcGroup.make(
704 WsServerGetTraceDiagnosticsRpc,767 WsServerGetTraceDiagnosticsRpc,
705 WsServerGetProcessDiagnosticsRpc,768 WsServerGetProcessDiagnosticsRpc,
706 WsServerGetProcessResourceHistoryRpc,769 WsServerGetProcessResourceHistoryRpc,
770 WsServerGetResourceTelemetryHistoryRpc,
771 WsServerRetryResourceTelemetryRpc,
707 WsServerSignalProcessRpc,772 WsServerSignalProcessRpc,
773 WsServerReportClientActivityRpc,
774 WsServerReportHostPowerStateRpc,
775 WsServerGetBackgroundPolicyRpc,
708 WsCloudGetRelayClientStatusRpc,776 WsCloudGetRelayClientStatusRpc,
709 WsCloudInstallRelayClientRpc,777 WsCloudInstallRelayClientRpc,
710 WsSourceControlLookupRepositoryRpc,778 WsSourceControlLookupRepositoryRpc,
...@@ -754,6 +822,8 @@ export const WsRpcGroup = RpcGroup.make(...@@ -754,6 +822,8 @@ export const WsRpcGroup = RpcGroup.make(
754 WsSubscribeServerConfigRpc,822 WsSubscribeServerConfigRpc,
755 WsSubscribeServerLifecycleRpc,823 WsSubscribeServerLifecycleRpc,
756 WsSubscribeAuthAccessRpc,824 WsSubscribeAuthAccessRpc,
825 WsSubscribeBackgroundPolicyRpc,
826 WsSubscribeResourceTelemetryRpc,
757 WsOrchestrationDispatchCommandRpc,827 WsOrchestrationDispatchCommandRpc,
758 WsOrchestrationGetTurnDiffRpc,828 WsOrchestrationGetTurnDiffRpc,
759 WsOrchestrationGetFullThreadDiffRpc,829 WsOrchestrationGetFullThreadDiffRpc,
packages/contracts/src/server.ts+2
...@@ -303,6 +303,7 @@ export type ServerProcessSignal = typeof ServerProcessSignal.Type;...@@ -303,6 +303,7 @@ export type ServerProcessSignal = typeof ServerProcessSignal.Type;
303303
304export const ServerProcessDiagnosticsEntry = Schema.Struct({304export const ServerProcessDiagnosticsEntry = Schema.Struct({
305 pid: PositiveInt,305 pid: PositiveInt,
306 startTimeMs: NonNegativeInt,
306 ppid: NonNegativeInt,307 ppid: NonNegativeInt,
307 pgid: Schema.Option(Schema.Int),308 pgid: Schema.Option(Schema.Int),
308 status: TrimmedNonEmptyString,309 status: TrimmedNonEmptyString,
...@@ -395,6 +396,7 @@ export type ServerProcessResourceHistoryResult = typeof ServerProcessResourceHis...@@ -395,6 +396,7 @@ export type ServerProcessResourceHistoryResult = typeof ServerProcessResourceHis
395396
396export const ServerSignalProcessInput = Schema.Struct({397export const ServerSignalProcessInput = Schema.Struct({
397 pid: PositiveInt,398 pid: PositiveInt,
399 startTimeMs: NonNegativeInt,
398 signal: ServerProcessSignal,400 signal: ServerProcessSignal,
399});401});
400export type ServerSignalProcessInput = typeof ServerSignalProcessInput.Type;402export type ServerSignalProcessInput = typeof ServerSignalProcessInput.Type;
packages/contracts/src/settings.ts+61
...@@ -426,15 +426,66 @@ export const SourceControlWritingStyleSettings = Schema.Struct({...@@ -426,15 +426,66 @@ export const SourceControlWritingStyleSettings = Schema.Struct({
426export type SourceControlWritingStyleSettings = typeof SourceControlWritingStyleSettings.Type;426export type SourceControlWritingStyleSettings = typeof SourceControlWritingStyleSettings.Type;
427427
428export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30);428export 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
430export const ServerSettings = Schema.Struct({470export const ServerSettings = Schema.Struct({
431 enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),471 enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
432 enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),472 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.
433 automaticGitFetchInterval: Schema.DurationFromMillis.pipe(476 automaticGitFetchInterval: Schema.DurationFromMillis.pipe(
434 Schema.withDecodingDefault(477 Schema.withDecodingDefault(
435 Effect.succeed(Duration.toMillis(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)),478 Effect.succeed(Duration.toMillis(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)),
436 ),479 ),
437 ),480 ),
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 ),
438 defaultThreadEnvMode: ThreadEnvMode.pipe(489 defaultThreadEnvMode: ThreadEnvMode.pipe(
439 Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)),490 Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)),
440 ),491 ),
...@@ -576,7 +627,17 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -576,7 +627,17 @@ export const ServerSettingsPatch = Schema.Struct({
576 // Server settings627 // Server settings
577 enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),628 enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),
578 enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),629 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 ),
579 automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis),638 automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis),
639 providerHealthRefreshInterval: Schema.optionalKey(Schema.DurationFromMillis),
640 backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile),
580 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),641 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
581 newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),642 newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),
582 addProjectBaseDirectory: Schema.optionalKey(TrimmedString),643 addProjectBaseDirectory: Schema.optionalKey(TrimmedString),
packages/shared/package.json+4
...@@ -79,6 +79,10 @@...@@ -79,6 +79,10 @@
79 "types": "./src/serverSettings.ts",79 "types": "./src/serverSettings.ts",
80 "import": "./src/serverSettings.ts"80 "import": "./src/serverSettings.ts"
81 },81 },
82 "./backgroundActivitySettings": {
83 "types": "./src/backgroundActivitySettings.ts",
84 "import": "./src/backgroundActivitySettings.ts"
85 },
82 "./String": {86 "./String": {
83 "types": "./src/String.ts",87 "types": "./src/String.ts",
84 "import": "./src/String.ts"88 "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", () => {...@@ -127,6 +127,25 @@ describe("RotatingFileSink", () => {
127 expect((thrown as RotatingFileSinkError).cause).toBeInstanceOf(Error);127 expect((thrown as RotatingFileSinkError).cause).toBeInstanceOf(Error);
128 });128 });
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
130 it("preserves backup pruning failures", () => {149 it("preserves backup pruning failures", () => {
131 const directory = makeTempDirectory();150 const directory = makeTempDirectory();
132 const filePath = NodePath.join(directory, "log.ndjson");151 const filePath = NodePath.join(directory, "log.ndjson");
packages/shared/src/logging.ts-4
...@@ -93,10 +93,6 @@ export class RotatingFileSink {...@@ -93,10 +93,6 @@ export class RotatingFileSink {
9393
94 NodeFS.appendFileSync(this.filePath, buffer);94 NodeFS.appendFileSync(this.filePath, buffer);
95 this.currentSize += buffer.length;95 this.currentSize += buffer.length;
96
97 if (this.currentSize > this.maxBytes) {
98 this.rotate();
99 }
100 } catch (cause) {96 } catch (cause) {
101 if (isRotatingFileSinkError(cause)) {97 if (isRotatingFileSinkError(cause)) {
102 throw cause;98 throw cause;
packages/shared/src/observability.test.ts+95-1
...@@ -8,6 +8,7 @@ import * as Layer from "effect/Layer";...@@ -8,6 +8,7 @@ import * as Layer from "effect/Layer";
8import * as Logger from "effect/Logger";8import * as Logger from "effect/Logger";
9import * as Order from "effect/Order";9import * as Order from "effect/Order";
10import * as Path from "effect/Path";10import * as Path from "effect/Path";
11import * as Ref from "effect/Ref";
11import * as References from "effect/References";12import * as References from "effect/References";
12import * as Schema from "effect/Schema";13import * as Schema from "effect/Schema";
13import * as Tracer from "effect/Tracer";14import * as Tracer from "effect/Tracer";
...@@ -19,6 +20,7 @@ import {...@@ -19,6 +20,7 @@ import {
19 makeLocalFileTracer,20 makeLocalFileTracer,
20 makeTraceSink,21 makeTraceSink,
21 type TraceRecord,22 type TraceRecord,
23 type TraceSinkFlushStats,
22} from "./observability.ts";24} from "./observability.ts";
2325
24describe("errorTag", () => {26describe("errorTag", () => {
...@@ -176,6 +178,34 @@ describe("observability", () => {...@@ -176,6 +178,34 @@ describe("observability", () => {
176 ),178 ),
177 );179 );
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
179 it.effect("rotates the trace file when the configured max size is exceeded", () =>209 it.effect("rotates the trace file when the configured max size is exceeded", () =>
180 Effect.scoped(210 Effect.scoped(
181 Effect.gen(function* () {211 Effect.gen(function* () {
...@@ -186,7 +216,7 @@ describe("observability", () => {...@@ -186,7 +216,7 @@ describe("observability", () => {
186216
187 const sink = yield* makeTraceSink({217 const sink = yield* makeTraceSink({
188 filePath: tracePath,218 filePath: tracePath,
189 maxBytes: 180,219 maxBytes: 500,
190 maxFiles: 2,220 maxFiles: 2,
191 batchWindowMs: 10_000,221 batchWindowMs: 10_000,
192 });222 });
...@@ -217,6 +247,70 @@ describe("observability", () => {...@@ -217,6 +247,70 @@ describe("observability", () => {
217 ),247 ),
218 );248 );
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
220 it.effect("drops only the invalid trace record when serialization fails", () =>314 it.effect("drops only the invalid trace record when serialization fails", () =>
221 Effect.scoped(315 Effect.scoped(
222 Effect.gen(function* () {316 Effect.gen(function* () {
packages/shared/src/observability.ts+63-7
...@@ -8,7 +8,8 @@ import { OtlpResource, OtlpTracer } from "effect/unstable/observability";...@@ -8,7 +8,8 @@ import { OtlpResource, OtlpTracer } from "effect/unstable/observability";
88
9import { RotatingFileSink } from "./logging.ts";9import { RotatingFileSink } from "./logging.ts";
1010
11const FLUSH_BUFFER_THRESHOLD = 32;11const FLUSH_BUFFER_THRESHOLD = 256;
12const textEncoder = new TextEncoder();
1213
13export type TraceAttributes = Readonly<Record<string, unknown>>;14export type TraceAttributes = Readonly<Record<string, unknown>>;
1415
...@@ -109,6 +110,13 @@ export interface TraceSinkOptions {...@@ -109,6 +110,13 @@ export interface TraceSinkOptions {
109 readonly maxBytes: number;110 readonly maxBytes: number;
110 readonly maxFiles: number;111 readonly maxFiles: number;
111 readonly batchWindowMs: number;112 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;
112}120}
113121
114export interface TraceSink {122export interface TraceSink {
...@@ -275,26 +283,73 @@ export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: Trac...@@ -275,26 +283,73 @@ export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: Trac
275 filePath: options.filePath,283 filePath: options.filePath,
276 maxBytes: options.maxBytes,284 maxBytes: options.maxBytes,
277 maxFiles: options.maxFiles,285 maxFiles: options.maxFiles,
286 throwOnError: true,
278 });287 });
279288
280 let buffer: Array<string> = [];289 let buffer: Array<string> = [];
290 let pendingFlushStats: TraceSinkFlushStats = {
291 logicalWriteBytes: 0,
292 count: 0,
293 durationMs: 0,
294 };
281295
282 const flushUnsafe = () => {296 const flushUnsafe = () => {
283 if (buffer.length === 0) {297 if (buffer.length === 0) {
284 return;298 return;
285 }299 }
286300
287 const chunk = buffer.join("");301 const records = buffer;
288 buffer = [];302 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 {321 const chunk = records.slice(persistedCount, nextIndex).join("");
291 sink.write(chunk);322 const startedAt = performance.now();
292 } catch {323 try {
293 buffer.unshift(chunk);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;
294 }335 }
295 };336 };
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
299 yield* Effect.addFinalizer(() => flush.pipe(Effect.ignore));354 yield* Effect.addFinalizer(() => flush.pipe(Effect.ignore));
300 yield* Effect.forkScoped(355 yield* Effect.forkScoped(
...@@ -399,6 +454,7 @@ export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* (...@@ -399,6 +454,7 @@ export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* (
399 maxBytes: options.maxBytes,454 maxBytes: options.maxBytes,
400 maxFiles: options.maxFiles,455 maxFiles: options.maxFiles,
401 batchWindowMs: options.batchWindowMs,456 batchWindowMs: options.batchWindowMs,
457 ...(options.onFlush ? { onFlush: options.onFlush } : {}),
402 }));458 }));
403459
404 const delegate =460 const delegate =
packages/shared/src/serverSettings.test.ts+217
...@@ -4,7 +4,9 @@ import {...@@ -4,7 +4,9 @@ import {
4 ProviderInstanceId,4 ProviderInstanceId,
5 type ServerProvider,5 type ServerProvider,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import * as Duration from "effect/Duration";
7import { describe, expect, it } from "vite-plus/test";8import { describe, expect, it } from "vite-plus/test";
9import { resolveServerBackgroundActivitySettings } from "./backgroundActivitySettings.ts";
8import { createModelSelection } from "./model.ts";10import { createModelSelection } from "./model.ts";
9import {11import {
10 applyServerSettingsPatch,12 applyServerSettingsPatch,
...@@ -295,4 +297,219 @@ describe("serverSettings helpers", () => {...@@ -295,4 +297,219 @@ describe("serverSettings helpers", () => {
295 config: { homePath: "~/.codex" },297 config: { homePath: "~/.codex" },
296 });298 });
297 });299 });
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 });
298});515});
packages/shared/src/serverSettings.ts+76-2
...@@ -12,6 +12,12 @@ import * as Schema from "effect/Schema";...@@ -12,6 +12,12 @@ import * as Schema from "effect/Schema";
12import { deepMerge } from "./Struct.ts";12import { deepMerge } from "./Struct.ts";
13import { fromLenientJson } from "./schemaJson.ts";13import { fromLenientJson } from "./schemaJson.ts";
14import { createModelSelection } from "./model.ts";14import { createModelSelection } from "./model.ts";
15import {
16 getBackgroundActivityBaseProfile,
17 normalizeBackgroundActivitySettings,
18 normalizeServerBackgroundActivitySettings,
19 resolveBackgroundActivitySettings,
20} from "./backgroundActivitySettings.ts";
1521
16const ServerSettingsJson = fromLenientJson(ServerSettings);22const ServerSettingsJson = fromLenientJson(ServerSettings);
17const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson);23const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson);
...@@ -120,10 +126,64 @@ export function applyServerSettingsPatch(...@@ -120,10 +126,64 @@ export function applyServerSettingsPatch(
120 patch: ServerSettingsPatch,126 patch: ServerSettingsPatch,
121): ServerSettings {127): ServerSettings {
122 const selectionPatch = patch.textGenerationModelSelection;128 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;
124 const next = deepMerge(current, patchForMerge);171 const next = deepMerge(current, patchForMerge);
125 const nextWithReplacements = {172 const nextWithReplacementsBase = {
126 ...next,173 ...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 : {}),
127 ...(patch.providerInstances !== undefined187 ...(patch.providerInstances !== undefined
128 ? { providerInstances: patch.providerInstances }188 ? { providerInstances: patch.providerInstances }
129 : {}),189 : {}),
...@@ -131,6 +191,20 @@ export function applyServerSettingsPatch(...@@ -131,6 +191,20 @@ export function applyServerSettingsPatch(
131 ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection }191 ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection }
132 : {}),192 : {}),
133 ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}),193 ...(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,
134 };208 };
135 if (!selectionPatch) {209 if (!selectionPatch) {
136 return nextWithReplacements;210 return nextWithReplacements;
scripts/build-desktop-artifact.test.ts+50
...@@ -15,9 +15,11 @@ import {...@@ -15,9 +15,11 @@ import {
15 createBuildConfig,15 createBuildConfig,
16 DESKTOP_ELECTRON_LANGUAGES,16 DESKTOP_ELECTRON_LANGUAGES,
17 DESKTOP_FILE_EXCLUSIONS,17 DESKTOP_FILE_EXCLUSIONS,
18 DESKTOP_EXTRA_RESOURCES,
18 InvalidMacPasskeyRpDomainError,19 InvalidMacPasskeyRpDomainError,
19 InvalidMacPasskeyPublishableKeyError,20 InvalidMacPasskeyPublishableKeyError,
20 InvalidMockUpdateServerPortError,21 InvalidMockUpdateServerPortError,
22 UnsupportedDesktopBuildArchitectureError,
21 isMacPasskeySigningConfigurationError,23 isMacPasskeySigningConfigurationError,
22 LinuxIconResizeError,24 LinuxIconResizeError,
23 MacPasskeySigningConfigurationResolutionError,25 MacPasskeySigningConfigurationResolutionError,
...@@ -32,6 +34,8 @@ import {...@@ -32,6 +34,8 @@ import {
32 resolveDesktopProductName,34 resolveDesktopProductName,
33 resolveDesktopUpdateChannel,35 resolveDesktopUpdateChannel,
34 resolveDesktopWebAssetBrand,36 resolveDesktopWebAssetBrand,
37 resolveResourceMonitorRustTargets,
38 resourceMonitorExecutableName,
35 resolveGitHubPublishConfig,39 resolveGitHubPublishConfig,
36 resolveMockUpdateServerPort,40 resolveMockUpdateServerPort,
37 resolveMockUpdateServerUrl,41 resolveMockUpdateServerUrl,
...@@ -543,6 +547,26 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -543,6 +547,26 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
543 }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),547 }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))),
544 );548 );
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 });
546 it("promotes target fff binaries to direct staged dependencies", () => {570 it("promotes target fff binaries to direct staged dependencies", () => {
547 assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), {571 assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), {
548 "@ff-labs/fff-bin-darwin-arm64": "0.9.4",572 "@ff-labs/fff-bin-darwin-arm64": "0.9.4",
...@@ -673,6 +697,32 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -673,6 +697,32 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
673 }),697 }),
674 );698 );
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
676 it.effect("preserves explicit false boolean flags over true env defaults", () =>726 it.effect("preserves explicit false boolean flags over true env defaults", () =>
677 Effect.gen(function* () {727 Effect.gen(function* () {
678 const resolved = yield* resolveBuildOptions({728 const resolved = yield* resolveBuildOptions({
scripts/build-desktop-artifact.ts+144
...@@ -94,6 +94,26 @@ interface PlatformConfig {...@@ -94,6 +94,26 @@ interface PlatformConfig {
94 readonly archChoices: ReadonlyArray<typeof BuildArch.Type>;94 readonly archChoices: ReadonlyArray<typeof BuildArch.Type>;
95}95}
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
97const PLATFORM_CONFIG: Record<typeof BuildPlatform.Type, PlatformConfig> = {117const PLATFORM_CONFIG: Record<typeof BuildPlatform.Type, PlatformConfig> = {
98 mac: {118 mac: {
99 cliFlag: "--mac",119 cliFlag: "--mac",
...@@ -189,6 +209,19 @@ export class UnsupportedHostBuildPlatformError extends Schema.TaggedErrorClass<U...@@ -189,6 +209,19 @@ export class UnsupportedHostBuildPlatformError extends Schema.TaggedErrorClass<U
189 }209 }
190}210}
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
192const InvalidMockUpdateServerPortReason = Schema.Literals([225const InvalidMockUpdateServerPortReason = Schema.Literals([
193 "not-numeric",226 "not-numeric",
194 "not-integer",227 "not-integer",
...@@ -236,6 +269,20 @@ export class BuildCommandFailedError extends Schema.TaggedErrorClass<BuildComman...@@ -236,6 +269,20 @@ export class BuildCommandFailedError extends Schema.TaggedErrorClass<BuildComman
236 }269 }
237}270}
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
239const desktopIconPlatformNames = {286const desktopIconPlatformNames = {
240 mac: "macOS",287 mac: "macOS",
241 linux: "Linux",288 linux: "Linux",
...@@ -593,6 +640,12 @@ export const DESKTOP_FILE_EXCLUSIONS = [...@@ -593,6 +640,12 @@ export const DESKTOP_FILE_EXCLUSIONS = [
593// The Windows primary backend reads the same files through the asar redirect,640// The Windows primary backend reads the same files through the asar redirect,
594// so nothing is duplicated.641// so nothing is duplicated.
595export const WINDOWS_ASAR_UNPACK = ["apps/server/dist/**", "**/node_modules/**"] as const;642export 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
597export interface MacPasskeySigningConfiguration {650export interface MacPasskeySigningConfiguration {
598 readonly appId: string;651 readonly appId: string;
...@@ -1044,6 +1097,14 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (...@@ -1044,6 +1097,14 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (
1044 const target = mergeOptions(input.target, env.target, PLATFORM_CONFIG[platform].defaultTarget);1097 const target = mergeOptions(input.target, env.target, PLATFORM_CONFIG[platform].defaultTarget);
1045 const defaultArch = yield* getDefaultArch(platform);1098 const defaultArch = yield* getDefaultArch(platform);
1046 const arch = mergeOptions(input.arch, env.arch, defaultArch);1099 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 }
1047 const version = mergeOptions(input.buildVersion, env.version, undefined);1108 const version = mergeOptions(input.buildVersion, env.version, undefined);
1048 const releaseDir = resolveBooleanFlag(input.mockUpdates, env.mockUpdates)1109 const releaseDir = resolveBooleanFlag(input.mockUpdates, env.mockUpdates)
1049 ? "release-mock"1110 ? "release-mock"
...@@ -1117,6 +1178,81 @@ const runCommand = Effect.fn("runCommand")(function* (...@@ -1117,6 +1178,81 @@ const runCommand = Effect.fn("runCommand")(function* (
1117 }1178 }
1118});1179});
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
1120function generateMacIconSet(1256function generateMacIconSet(
1121 sourcePng: string,1257 sourcePng: string,
1122 targetIcns: string,1258 targetIcns: string,
...@@ -1412,6 +1548,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (...@@ -1412,6 +1548,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
1412 // WINDOWS_ASAR_UNPACK); macOS and Linux stay packed — smart unpack1548 // WINDOWS_ASAR_UNPACK); macOS and Linux stay packed — smart unpack
1413 // extracts native libraries, which fff-node finds in app.asar.unpacked.1549 // extracts native libraries, which fff-node finds in app.asar.unpacked.
1414 ...(platform === "win" ? { asarUnpack: [...WINDOWS_ASAR_UNPACK] } : {}),1550 ...(platform === "win" ? { asarUnpack: [...WINDOWS_ASAR_UNPACK] } : {}),
1551 extraResources: DESKTOP_EXTRA_RESOURCES,
1415 };1552 };
1416 const updateChannel = resolveDesktopUpdateChannel(version);1553 const updateChannel = resolveDesktopUpdateChannel(version);
1417 const publishConfig = yield* resolveGitHubPublishConfig(updateChannel);1554 const publishConfig = yield* resolveGitHubPublishConfig(updateChannel);
...@@ -1691,6 +1828,13 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -1691,6 +1828,13 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
1691 yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"));1828 yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"));
1692 yield* fs.copy(distDirs.desktopResources, stageResourcesDir);1829 yield* fs.copy(distDirs.desktopResources, stageResourcesDir);
1693 yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"));1830 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
1695 yield* assertPlatformBuildResources(1839 yield* assertPlatformBuildResources(
1696 options.platform,1840 options.platform,