Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ live in [docs/development/gcloud-robot.md](docs/development/gcloud-robot.md).

### Key Architectural Patterns

**WebSocket Protocol:** Schema-validated messages using Zod. Handshake flow: client sends `hello` with token → server validates → sends `ready`. Message types include `terminal.create/input/resize/detach/attach` and broadcasts like `sessions.updated`.
**WebSocket Protocol:** Schema-validated messages using Zod. Handshake flow: client sends `hello` with token → server validates → sends `ready`. Message types include `terminal.create/input/resize/detach/attach` and broadcasts like `sessions.updated`. The `ready` frame carries an optional additive `buildId` (the server's artifact-time-baked git commit, `"unknown"` fallback): the client bakes its own at Vite build time (`__FRESHELL_BUILD_ID__`) and, on a mismatch, reloads exactly once per tab session (sessionStorage sentinel `freshell.server-build-reload` records the last attempted server build id; the same id never reloads twice, a different (corrected) deployment re-arms the guard), self-healing stale-client contract errors; `"unknown"` on either side never triggers or clears the guard (`src/lib/server-build-check.ts`). The once-guard is per server identity: an origin fronted by mixed-build servers could oscillate, and a newer client against an older server costs one futile bounded reload per fresh tab session (both accepted for the single-server self-hosted model).

**PTY Lifecycle:** Each terminal has a unique ID. Server maintains 64KB scrollback buffer. On attach, client receives buffer snapshot then streams new output. On detach, process continues running (background session). Configurable idle timeout (15 mins default).

Expand Down
22 changes: 22 additions & 0 deletions config/vite/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,33 @@ import type { HttpProxy } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
import { fileURLToPath } from 'url'
import { execFileSync } from 'node:child_process'
import { getNetworkHost } from '../../server/get-network-host.js'

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const projectRoot = path.resolve(__dirname, '../..')

/**
* The client's build identity: the git commit the bundle was built from,
* matching the server-side stamps (`crates/freshell-ws/build.rs` /
* `server/build-id.ts` + `scripts/bake-server-build-id.mjs`). `"unknown"`
* fallback — the client's compare rule ignores `"unknown"` on both sides.
*/
function computeClientBuildId(): string {
try {
const sha = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: projectRoot,
stdio: ['ignore', 'pipe', 'ignore'],
})
.toString()
.trim()
return /^[0-9a-f]{40}$/.test(sha) ? sha : 'unknown'
} catch {
return 'unknown'
}
}

/**
* Transport-level proxy failures that mean "the backend is down or restarting":
* refused (not yet listening), reset/pipe (killed mid-request), timeout/host
Expand Down Expand Up @@ -57,6 +78,7 @@ export default defineConfig(({ mode }) => {
plugins: [react()],
define: {
__PERF_LOGGING__: JSON.stringify(env.PERF_LOGGING || ''),
__FRESHELL_BUILD_ID__: JSON.stringify(computeClientBuildId()),
},
resolve: {
alias: {
Expand Down
6 changes: 6 additions & 0 deletions crates/freshell-protocol/src/server_messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,12 @@ pub struct Ready {
pub boot_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub server_instance_id: Option<String>,
/// The git commit this server binary was built from (`"unknown"`
/// fallback), stamped so the browser client can detect a client/server
/// build mismatch and reload once. Omitted from the wire entirely when
/// `None` (frozen-client inertness — same rule as `boot_id`).
#[serde(skip_serializing_if = "Option::is_none")]
pub build_id: Option<String>,
/// Reconciliation-handshake advertisement (§4.2): `Some` only when the
/// client's `hello` opted in via `capabilities.paneReconcileV1`. A client
/// must not send `pane.reconcile.request` unless the `ready` it just
Expand Down
2 changes: 2 additions & 0 deletions crates/freshell-protocol/tests/pane_reconcile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ fn ready_capabilities_field_is_omitted_when_none() {
timestamp: "2026-07-22T00:00:00.000Z".to_string(),
boot_id: Some("boot-1".to_string()),
server_instance_id: Some("srv-1".to_string()),
build_id: None,
capabilities: None,
};
let wire = serde_json::to_value(ServerMessage::Ready(ready)).expect("serializes");
Expand All @@ -72,6 +73,7 @@ fn ready_capabilities_advertise_pane_reconcile_v1_when_negotiated() {
timestamp: "2026-07-22T00:00:00.000Z".to_string(),
boot_id: Some("boot-1".to_string()),
server_instance_id: Some("srv-1".to_string()),
build_id: None,
capabilities: Some(ReadyCapabilities {
pane_reconcile_v1: Some(true),
pane_reconcile_fresh_agent_v1: None,
Expand Down
30 changes: 30 additions & 0 deletions crates/freshell-protocol/tests/roundtrip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,36 @@ fn ready_carries_server_instance_id_and_boot_id() {
}
}

#[test]
fn ready_carries_build_id_and_omits_it_when_absent() {
// deliverable: `ready` accepts an additive optional `buildId` (the git
// commit the server binary was built from) and OMITS it from the wire
// when absent — frozen-transcript inertness, same rule as `bootId`.
let with = r#"{"type":"ready","timestamp":"2026-07-05T04:20:52.546Z","serverInstanceId":"srv-abc","buildId":"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"}"#;
match server_roundtrip(with, "ready") {
ServerMessage::Ready(r) => {
assert_eq!(
r.build_id.as_deref(),
Some("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2")
);
}
other => panic!("expected Ready, got {other:?}"),
}

let without =
r#"{"type":"ready","timestamp":"2026-07-05T04:20:52.546Z","serverInstanceId":"srv-abc"}"#;
let msg: ServerMessage = serde_json::from_str(without).unwrap();
let reser = serde_json::to_value(&msg).unwrap();
assert!(
reser.get("buildId").is_none(),
"ready must omit buildId when absent: {reser}"
);
match msg {
ServerMessage::Ready(r) => assert_eq!(r.build_id, None),
other => panic!("expected Ready, got {other:?}"),
}
}

#[test]
fn terminal_inventory_and_settings_parse_from_transcript() {
let transcript = read_json("port/oracle/fixtures/handshake-transcript.json");
Expand Down
75 changes: 75 additions & 0 deletions crates/freshell-ws/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//! Compile-time build-provenance stamp for `freshell-ws`: bakes the git
//! commit SHA into `FRESHELL_WS_BUILD_COMMIT` so the WS handshake's `ready`
//! can stamp `ready.buildId` (client-side stale-bundle auto-reload).
//! Build provenance is BUILD-scoped, not boot-scoped, so it deliberately
//! does NOT ride on `WsState` (whose contents are boot-scoped ids/state
//! injected by `freshell-server`). The full worktree-aware rationale for
//! the `rerun-if-changed` set lives in `crates/freshell-server/build.rs` —
//! this copy performs the SAME resolved-HEAD/ref/packed-refs watching so a
//! cached rebuild re-stamps when HEAD moves; both crates compile in the
//! same workspace build, so their baked commits agree. Never fails the
//! build over a missing/unavailable `git` (falls back to `"unknown"`).

use std::path::PathBuf;
use std::process::Command;

fn main() {
let commit = git_head_commit().unwrap_or_else(|| "unknown".to_string());
println!("cargo:rustc-env=FRESHELL_WS_BUILD_COMMIT={commit}");
for path in rerun_paths() {
println!("cargo:rerun-if-changed={}", path.display());
}
}

/// `git rev-parse HEAD`, trimmed. `None` on any failure (git not on `PATH`,
/// not inside a git checkout, ...) -- the caller falls back to `"unknown"`.
fn git_head_commit() -> Option<String> {
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}

/// The exact paths that change when HEAD moves in THIS checkout, resolved
/// worktree-aware via `git rev-parse --git-path` (see the module doc and
/// `crates/freshell-server/build.rs`'s richer version for why each entry is
/// watched). Skipped resolutions degrade to cargo's default heuristics.
fn rerun_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
let git_path = |arg: &str| {
Command::new("git")
.args(["rev-parse", "--git-path", arg])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| PathBuf::from(String::from_utf8_lossy(&o.stdout).trim()))
.filter(|p| !p.as_os_str().is_empty())
};
if let Some(head) = git_path("HEAD") {
paths.push(head);
}
if let Some(head) = git_path("HEAD") {
if let Ok(contents) = std::fs::read_to_string(&head) {
if let Some(ref_name) = contents.strip_prefix("ref: ") {
if let Some(resolved) = git_path(ref_name.trim()) {
paths.push(resolved);
}
}
}
}
if let Some(packed) = git_path("packed-refs") {
if packed.exists() {
paths.push(packed);
}
}
paths
}
42 changes: 40 additions & 2 deletions crates/freshell-ws/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@
//! The crate emits the frozen [`freshell_protocol`] server-message types so its
//! wire bytes are contract-locked.

/// The git commit THIS binary was built from, baked into this crate at
/// compile time by this crate's `build.rs` (`FRESHELL_WS_BUILD_COMMIT`).
/// Falls back to the literal `"unknown"` when git was unavailable at build
/// time (e.g. a source tarball or the Cloud Run image, which builds without
/// git metadata) -- never a runtime failure. Build provenance is
/// BUILD-scoped, so this deliberately does NOT ride on `WsState`.
pub fn ready_build_id() -> Option<String> {
Some(
option_env!("FRESHELL_WS_BUILD_COMMIT")
.unwrap_or("unknown")
.to_string(),
)
}

pub mod activity;
pub mod auto_resume;
pub mod backpressure;
Expand Down Expand Up @@ -517,8 +531,11 @@ pub async fn build_handshake(state: &WsState) -> Vec<ServerMessage> {
/// [`build_handshake`], parameterized on the connection's negotiated
/// `hello.capabilities.paneReconcileV1` (reconciliation design §4.2): the
/// `ready.capabilities` advertisement is emitted **only when the client's
/// `hello` opted in** — today's frozen client doesn't, so the emitted
/// handshake stays byte-for-byte identical to the pinned clean-boot shape.
/// `hello` opted in** — today's frozen client doesn't, so that field stays
/// omitted for it (frozen-client inertness). The handshake overall is no
/// longer byte-for-byte identical to the pinned clean-boot shape: `ready`
/// now always stamps `buildId`, an additive change old clients ignore as
/// an unknown field.
///
/// CFG-12: `settings.updated` resolves [`WsState::handshake_settings`] — the
/// LIVE tree — fresh on every call (one call per `/ws` connection), matching
Expand All @@ -537,6 +554,7 @@ pub async fn build_handshake_with_capabilities(
timestamp: now_iso(),
boot_id: Some(boot_id.clone()),
server_instance_id: Some(state.server_instance_id.as_ref().clone()),
build_id: ready_build_id(),
capabilities: (pane_reconcile_v1 || pane_reconcile_fresh_agent_v1).then_some(
freshell_protocol::ReadyCapabilities {
pane_reconcile_v1: pane_reconcile_v1.then_some(true),
Expand Down Expand Up @@ -1025,6 +1043,26 @@ mod tests {
assert_eq!(wire[3]["terminalMeta"], json!([]));
}

/// The handshake `ready` stamps the build identity baked into THIS crate
/// by its `build.rs` (`FRESHELL_WS_BUILD_COMMIT`, the git commit the
/// binary was built from) so the browser client can detect a client/
/// server build mismatch and reload once. Never absent on the wire from
/// a real server: the baked value is always `Some` (sha or `"unknown"`).
#[tokio::test]
async fn handshake_ready_stamps_build_id() {
let msgs = build_handshake(&state()).await;
let ready = serde_json::to_value(&msgs[0]).unwrap();
assert!(
ready.get("buildId").is_some(),
"ready must stamp buildId: {ready}"
);
let build_id = ready["buildId"].as_str().expect("buildId is a string");
assert!(
!build_id.is_empty(),
"buildId must be non-empty: {build_id}"
);
}

/// GAP1 (CFG-03 checklist follow-up) RED/GREEN target: when boot fell
/// back, `config.fallback` slots into the ordered handshake right after
/// `perf.logging` and before `terminal.inventory` -- mirrors the
Expand Down
Loading
Loading