The app hands its log to Dev Updater on the phone, not through ai-server

Iris's call once the upload route was working: put it in Dev Updater
properly. So the app now exposes its own ring through a ContentProvider
at `<applicationId>.devlog` -- Dev Updater's contract, written down in
that project's README, not something invented here -- and Dev Updater's
phone app reads it on the same device and forwards it to its own build
machine. No tunnel, no token, no second enrolment, and any app that
server delivers can implement the same and get the same Runtime tab.

`DevLogProvider.java` plus `devlog.rs` are the platform glue only: a flat
`String[]` across JNI, a `MatrixCursor` on the Java side, and
`nativeReady` telling Rust the authority the provider actually
registered, so the Diagnostics pane can name somewhere a reader can
query rather than composing a guess. `LogRing::newest_seq()` is the one
addition in `client-core`: an in-memory ring starts again at zero, so it
is what lets a reader notice the process restarted instead of silently
skipping everything since.

Deleted with it, so there is one mechanism: `client_core::log_upload`,
`POST /client-log` on ai-server, the `AI_APP_LOG_*` baking (which left
`build.rs` with nothing to do), and the uploader on both Android
clients. Kept: the ring, `RingLogger`, `install_process_logger`, and the
Diagnostics line -- whose second half is now `devlog provider:
content://<authority>`.

Verified end to end on this checkout's emulator: iris's own
`iris::android::view` startup lines read out of the provider by the
shell, forwarded by Dev Updater's Runtime tab, and served back from
`GET /apps/android-app/components/app/logs?kind=runtime`. A component
whose package has no provider says so in as many words.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-07 20:58:48 -04:00
1 parent e10582a2cd
commit 06b8a1f4b0
15 files changed
+618 -842

No files matched your search

-119
View File
@@ -62,9 +62,6 @@
//! once the account's usage limit lifts
//! GET /notifications SSE: every session's attention-wanting
//! moments, live only (see `notifications`)
//! POST /client-log {source, lines} -- a client's own recent log,
//! re-emitted into this server's log (see
//! `client_log`; the phone has no logcat)
//! GET /defaults {effort} -- what a new session starts at
//! POST /defaults {effort} -- null for the CLI's own default
//! GET /usage cached usage windows per provider
@@ -161,7 +158,6 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
.route("/sessions/{id}/permission-mode", post(set_permission_mode))
.route("/sessions/{id}/effort", post(set_effort))
.route("/defaults", get(defaults).post(set_defaults))
.route("/client-log", post(client_log))
.route("/sessions/{id}/notify", post(set_notify))
.route("/sessions/{id}/auto-resume", post(set_auto_resume))
.route("/notifications", get(notifications))
@@ -1411,121 +1407,6 @@ async fn defaults(State(manager): State<Arc<SessionManager>>) -> axum::Json<Defa
})
}
/// How many lines one `POST /client-log` may carry, matching
/// `client_core::log_upload::MAX_LINES_PER_BATCH`. A client that sends more
/// is refused rather than silently shortened: a log with a hole in it that
/// nothing mentions is worse than a rejected batch the client retries.
const CLIENT_LOG_MAX_LINES: usize = 500;
/// The tracing target every re-emitted client line carries.
///
/// **Under `ai_server::`, deliberately.** A bare `client_log` target is
/// filtered out by `RUST_LOG=ai_server=debug` -- the exact filter
/// AGENTS.md tells people to run with -- so every line a phone sent would
/// vanish with nothing saying so. Under the crate's own path it is on
/// wherever the server's own lines are, which is the only filter its
/// reader knows about.
const CLIENT_LOG_TARGET: &str = "ai_server::client_log";
/// One line of a client's own log. `at` is that client's clock, not this
/// machine's -- see [`client_log`].
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct ClientLogLine {
/// The client's ring sequence. Carried so a gap -- lines its bound
/// dropped -- is visible here rather than looking like a quiet client.
seq: u64,
/// Milliseconds since the unix epoch, from the client's own clock.
at: u64,
level: String,
target: String,
message: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct ClientLogBody {
/// Which client this is -- an app name and build, not a device
/// identifier. It is what tells two phones' lines apart in the log.
source: String,
lines: Vec<ClientLogLine>,
}
/// Takes a client's own recent log lines and re-emits them into this
/// server's `tracing` output.
///
/// **Why a route rather than something on the phone**: Android forbids one
/// app reading another's `logcat`, and the phone this project is tested on
/// has no `adb` at all, so a `log::info!` in the app can only reach a
/// person if the app carries its own copy and sends it somewhere. This
/// server is the somewhere it already has a tunnel, a pinned CA and a
/// bearer token for -- and Dev Updater already shows this server's log as
/// its runtime log, so the line arrives where its reader is already
/// looking with nothing new built there. `docs/DECISIONS.md`, 2026-09-07.
///
/// Each line is emitted separately, at the level the client recorded it
/// at, with the client's own timestamp in the text -- the tracing
/// subscriber stamps the moment of *arrival*, which can be minutes later
/// or on the other side of a tunnel outage, and presenting that as when it
/// happened would be an inferred value shown as a measured one.
async fn client_log(axum::Json(body): axum::Json<ClientLogBody>) -> Result<StatusCode, ApiError> {
if body.lines.len() > CLIENT_LOG_MAX_LINES {
return Err(ApiError::BadRequest(format!(
"{} lines in one batch; the limit is {CLIENT_LOG_MAX_LINES}",
body.lines.len()
)));
}
for line in &body.lines {
let at = client_log_time(line.at);
let source = &body.source;
let target = &line.target;
let seq = line.seq;
let message = &line.message;
// The level is chosen here rather than passed, because a tracing
// macro's level is part of the callsite. Anything unrecognised is
// reported at INFO with the word it sent kept, so a client using a
// level this server has not heard of loses the level rather than
// the line.
match line.level.to_ascii_uppercase().as_str() {
"ERROR" => {
tracing::error!(target: CLIENT_LOG_TARGET, "[{source} {at} #{seq}] {target}: {message}")
}
"WARN" => {
tracing::warn!(target: CLIENT_LOG_TARGET, "[{source} {at} #{seq}] {target}: {message}")
}
"DEBUG" => {
tracing::debug!(target: CLIENT_LOG_TARGET, "[{source} {at} #{seq}] {target}: {message}")
}
"TRACE" => {
tracing::trace!(target: CLIENT_LOG_TARGET, "[{source} {at} #{seq}] {target}: {message}")
}
"INFO" => {
tracing::info!(target: CLIENT_LOG_TARGET, "[{source} {at} #{seq}] {target}: {message}")
}
other => {
tracing::info!(target: CLIENT_LOG_TARGET, "[{source} {at} #{seq}] {target}: <{other}> {message}")
}
}
}
Ok(StatusCode::NO_CONTENT)
}
/// `HH:MM:SS.mmm` UTC from the client's unix milliseconds -- the same
/// formatting `client_core::log_ring` uses, so a line read here and the
/// same line in the app's own copied report say the same time.
fn client_log_time(at_ms: u64) -> String {
let secs_of_day = (at_ms / 1000) % 86_400;
format!(
"{:02}:{:02}:{:02}.{:03}",
secs_of_day / 3600,
(secs_of_day % 3600) / 60,
secs_of_day % 60,
at_ms % 1000
)
}
/// Sets what a new session's thinking level is. Applied when a session is
/// spawned, so nothing already running changes underneath anybody.
async fn set_defaults(