1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
//! HTTP/SSE daemon surface: port binding, address discovery, and serving.
//!
//! Why: the axum HTTP server, its dynamic-port binding, the `http_addr`
//! discovery-file plumbing, and the SSE stream are a cohesive "how the daemon
//! is reachable" concern that is orthogonal to the `AppState` model in
//! `lib.rs`. Splitting it here keeps `lib.rs` under the SLOC cap and lets the
//! whole HTTP surface (all `axum-server`-gated) sit behind one module boundary.
//! What: exports `DEFAULT_HTTP_PORT`, `http_addr_path`, `bind_dynamic_port`,
//! `is_data_dir_override_active`, and the `run_http*` serving entry points
//! (re-exported at the crate root so existing `trusty_memory::run_http_on`
//! paths are unchanged).
//! Test: `lib_tests` covers the address-file helpers and port binding; the
//! serving entry points are covered by `web::tests` + manual `curl`.
use crateAppState;
use Result;
use SocketAddr;
use PathBuf;
// Why (issue #2319): `Path` (unlike `PathBuf`, used unconditionally by
// `http_addr_path` above) is only referenced by `write_http_addr_file`,
// which is itself gated behind `axum-server`. An ungated `use` here
// breaks `cargo check -p trusty-memory --no-default-features` under
// `-D warnings` (the single-package / feature-unification build CI
// runs) with an unused-import error. Mirrors the `tracing::info` gate
// immediately below.
use Path;
use info;
/// Preferred starting port for the trusty-memory HTTP daemon.
///
/// Why: keeps the well-known default stable for clients that have hard-coded
/// `127.0.0.1:7070` in their configuration, while still allowing dynamic
/// walking when the port is in use (`DYNAMIC_PORT_RANGE` ports starting here).
/// What: `7070` — historic default, matches the launchd plist's prior value.
/// Test: covered indirectly by `bind_dynamic_port_returns_listener`.
pub const DEFAULT_HTTP_PORT: u16 = 7070;
/// Number of consecutive ports `bind_dynamic_port` walks before falling back
/// to the OS-assigned port. Matches the trusty-search convention.
const DYNAMIC_PORT_RANGE: u16 = 10;
/// Path to the canonical address-discovery file for the trusty-memory daemon.
///
/// Why: clients (CLI, MCP tools, dashboards) need to find the running daemon
/// without configuration when the port was selected dynamically. Using
/// `trusty_common::resolve_data_dir` aligns this path with the location
/// that `trusty_common::read_daemon_addr("trusty-memory")` reads from, so
/// `prompt-context`, `doctor`, and `start`'s probe all find the running daemon.
/// The old `~/.trusty-memory/http_addr` path and the new
/// `~/Library/Application Support/trusty-memory/http_addr` (macOS) path were
/// divergent — the daemon wrote one; readers expected the other.
/// What: returns `{resolve_data_dir("trusty-memory")}/http_addr`, or `None` if
/// the data dir cannot be resolved (locked-down container, no passwd entry).
/// Test: `http_addr_path_uses_resolve_data_dir`.
/// Bind a `TcpListener` to `127.0.0.1`, dynamically selecting a port.
///
/// Why: the historic default `7070` is convenient for clients but a stale
/// process or a second daemon must not produce a noisy failure. Walking
/// `DEFAULT_HTTP_PORT..DEFAULT_HTTP_PORT+DYNAMIC_PORT_RANGE` first preserves
/// backwards compatibility for the common case; OS-assigned fallback (`:0`)
/// guarantees the daemon always comes up even when every preferred port is
/// busy.
/// What: returns the first successful `TcpListener` (7070..=7079, then
/// OS-assigned); caller inspects `local_addr()` to learn the chosen port.
/// Test: `bind_dynamic_port_returns_listener` confirms it always binds *some*
/// port even after another listener occupies the preferred one.
pub async
/// Write the bound `host:port` to `~/.trusty-memory/http_addr` atomically.
///
/// Why: clients must read the file mid-write without observing a partial
/// value. Writing to a `.tmp` sibling and renaming over the target gives
/// POSIX atomicity, matching the trusty-search implementation.
/// What: creates the parent directory if missing; writes `addr` followed by a
/// trailing newline (avoids the "no newline at end of file" warnings from
/// `cat`); renames `.tmp` → `http_addr`. Best-effort: I/O errors are
/// returned to the caller so `run_http_on` can log without panicking.
/// Test: `http_addr_file_round_trip_via_helpers`.
pub
/// Return `true` when a non-default data directory is in effect.
///
/// Why (issue #880): two startup side-effects must be suppressed when the
/// daemon runs with an isolated/overridden data root:
/// 1. The legacy `~/.trusty-memory/http_addr` dotfile write — it would
/// overwrite the real production daemon's discovery file with the isolated
/// instance's throwaway address.
/// 2. The startup pin-scan — it reads project pin files from the **real**
/// user environment (~/Projects, ~/Developer, …) and imports palaces from
/// the real environment into the isolated data root, defeating isolation.
///
/// A "non-default data dir" means `TRUSTY_DATA_DIR_OVERRIDE` is set to a
/// non-empty, non-whitespace value. Empty or whitespace-only values are
/// treated as unset (same rule as `resolve_data_dir`), so an accidental blank
/// env var does not suppress the dotfile write on real production instances.
/// What: reads `TRUSTY_DATA_DIR_OVERRIDE`; returns `true` when it contains a
/// non-empty, non-whitespace string. Returns `false` otherwise.
/// Test: `is_data_dir_override_active_when_set`,
/// `is_data_dir_override_inactive_when_unset`,
/// `is_data_dir_override_inactive_when_blank`.
/// Resolve the dotfile discovery path `~/.trusty-memory/http_addr`.
///
/// Why (issue #498): external tooling such as claude-mpm's `migrate_trusty_autodetect`
/// reads `~/.trusty-memory/http_addr` to find the running daemon's port. On
/// macOS, `resolve_data_dir("trusty-memory")` returns
/// `~/Library/Application Support/trusty-memory/`, not `~/.trusty-memory/`,
/// so the daemon was writing to the OS-standard location while readers expected
/// the dotfile location. Writing to both locations keeps every reader happy
/// regardless of which convention they follow.
///
/// Fix #880: returns `None` when `TRUSTY_DATA_DIR_OVERRIDE` is active so an
/// isolated instance (test rig, CI, parallel run) never overwrites the real
/// production daemon's discovery dotfile.
///
/// What: returns `$HOME/.trusty-memory/http_addr` in the default (production)
/// case, or `None` when `dirs::home_dir()` is unavailable OR when a data-dir
/// override is active (see `is_data_dir_override_active`).
/// Test: `dotfile_http_addr_path_uses_home_dir`,
/// `dotfile_suppressed_when_override_active`.
pub
/// Run the optional HTTP/SSE + web admin server.
///
/// Why: A long-running daemon mode lets non-stdio clients (browsers, curl,
/// future remote agents) hit `/health`, the `/api/v1/*` REST surface, and the
/// embedded admin SPA. The Unix-domain-socket transport and the
/// `trusty-memory-mcp-bridge` binary were removed in PR3 of the #914
/// stdio-cutover epic; the canonical MCP integration is now
/// `trusty-memory serve --stdio` (PR1 #919).
/// What: axum router built from `web::router()` plus a `/sse` stub for the
/// existing MCP-over-SSE clients. Caller provides a pre-bound listener so
/// port auto-detection lives at the call site. Before accepting connections
/// the daemon stamps the bound `host:port` onto `AppState.bound_addr` and
/// writes `~/.trusty-memory/http_addr` so clients can discover the live port.
/// On shutdown the file is removed best-effort (a stale file with the wrong
/// port is worse than a missing one).
/// Test: `cargo test -p trusty-memory web::tests` exercises the router shape;
/// manual: `curl http://127.0.0.1:<port>/health` returns `ok` with `addr`.
pub async
/// Convenience: bind `addr` and serve via [`run_http_on`].
pub async
/// Convenience: bind dynamically (7070..=7079, OS fallback) and serve.
///
/// Why: `trusty-memory serve` with no `--http` flag is the canonical
/// launchd-managed daemon entry point. Dynamic binding lets a stale daemon
/// or a hand-spawned `serve --http 127.0.0.1:7070` coexist without breaking
/// the launchd-managed instance.
/// What: calls [`bind_dynamic_port`] then [`run_http_on`].
/// Test: integration via `trusty-memory serve` + `cat ~/.trusty-memory/http_addr`.
pub async
/// Interval between recomputations of the data-root disk footprint.
///
/// Why (#4764): see the cadence note inside [`spawn_disk_size_ticker`].
const DISK_SIZE_INTERVAL: Duration = from_secs;
/// Spawn a background ticker that recomputes the `data_root` disk footprint
/// on a fixed cadence and stores it in `state.disk_bytes` (issue #35).
///
/// Why: `GET /health` reports `disk_bytes`. Walking the data directory on
/// every health request would turn a frequent health poll into unbounded
/// recursive I/O. Computing it off the request path on a fixed cadence keeps
/// `/health` cheap and bounds the staleness to [`DISK_SIZE_INTERVAL`] — fine
/// for an at-a-glance footprint figure.
/// What: spawns a detached tokio task. `AppState` is cheap to `Clone` (all
/// `Arc` fields), so the task holds a full clone; the daemon process lives
/// for the lifetime of the server anyway, so no `Weak` downgrade is needed.
/// Each tick runs the blocking directory walk on `spawn_blocking` so it never
/// stalls the async runtime, then stores the byte total atomically.
/// Test: `health_endpoint_includes_resource_fields` asserts the field shape;
/// the ticker cadence is not unit-tested (timing-dependent).
/// Interval between aggregate-status snapshot emits on the SSE bus.
///
/// Why (issue #228): mutations used to fire `StatusChanged` synchronously on
/// the write path, which forced an O(N palaces) sum of drawer / vector / KG
/// counts on every `memory_remember`. Coalescing into a fixed-cadence ticker
/// lets dashboards stay current (a 30 s lag is invisible at human scale)
/// while keeping the write path free of aggregate work.
/// What: 30 seconds — short enough that the operator UI doesn't feel stale
/// between manual writes, long enough that the recompute cost (in-memory
/// registry walk plus the redb `count_active_triples` per palace) is a
/// rounding error on the daemon's CPU budget.
/// Test: covered indirectly — the math has not changed, only the cadence.
const STATUS_EVENT_TICK_SECS: u64 = 30;
/// Spawn a background ticker that emits `DaemonEvent::StatusChanged` every
/// [`STATUS_EVENT_TICK_SECS`] seconds (issue #228).
///
/// Why: replaces the per-write `state.emit(self.aggregate_status_event())`
/// call sites that used to recompute the aggregate every time a drawer was
/// created or deleted. Walking N palaces on every write blocks the async
/// runtime; coalescing the emit onto a ticker keeps dashboards up-to-date
/// without that cost.
/// What: spawns a detached tokio task that holds a full `AppState` clone
/// (cheap — every field is `Arc`-backed) and ticks every
/// [`STATUS_EVENT_TICK_SECS`] seconds. Each tick computes
/// `MemoryService::aggregate_status_event` (which now iterates the
/// in-memory registry, not disk) and broadcasts it via `state.emit`. If
/// no SSE subscribers are connected the broadcast `send` is a cheap no-op,
/// so the ticker imposes no cost when nobody is listening.
/// Test: not unit-tested (timing-dependent fire-and-forget); the underlying
/// `aggregate_status_event` math is exercised by the existing
/// `status_endpoint_returns_payload` path.
/// Live SSE event stream — pushes `DaemonEvent` frames to dashboard clients.
///
/// Why: The dashboard subscribes once and reacts to live pushes (palace
/// created, drawer added/deleted, dream completed, status changed) instead of
/// polling `/api/v1/*` endpoints.
/// What: Subscribes to `state.events`, emits an initial `connected` frame,
/// then forwards every `DaemonEvent` as `data: <json>\n\n`. Lagged
/// subscribers receive a `lag` frame indicating skipped events; channel
/// closure ends the stream.
/// Test: `web::tests::sse_stream_emits_palace_created` (covers subscribe +
/// emit + receive); manual: `curl -N http://.../sse`.
pub async