Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# ts-rs (Rust -> TypeScript) export configuration.
# `cargo test --features ts` (or the frontend `gen:types` script) emits `.ts`
# files into the directory below. Generated types are NOT committed (see
# `.gitignore`); the `prebuild` step regenerates them locally and in CI.
# See docs/plans/step1-ts-rs-integration.md.
[env]
TS_RS_EXPORT_DIR = { value = "src/web-ui/src/generated/api", relative = true }
# Map i64/u64 to `number` (not `bigint`) so generated types match the existing
# frontend convention (timestamps are `number` ms throughout the web UI).
TS_RS_LARGE_INT = "number"
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ src/web-ui/src/generated/version.ts
src/web-ui/src/generated/version-injection.html
src/web-ui/public/version.json

# Generated TypeScript bindings (ts-rs) — single source is Rust schema.rs.
# Regenerated by `npm run gen:types` / build prebuild step.
src/web-ui/src/generated/api/

# Tauri generated files
apps/desktop/gen/
src/apps/desktop/gen/
Expand Down
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ members = [
"src/apps/server",
"src/apps/relay-server",
"src/crates/interfaces/acp",
"src/crates/interfaces/app-server",
"src/crates/interfaces/sdk-host",
"src/crates/adapters/agent-runtime-ipc",
"src/crates/assembly/core",
Expand Down Expand Up @@ -82,6 +83,9 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml = "0.9"

# TypeScript binding generation (schema-first; gated by per-crate `ts` features)
ts-rs = { version = "12", features = ["serde-json-impl", "no-serde-warnings"] }

# Error handling
anyhow = "1.0"
thiserror = "2"
Expand Down
254 changes: 254 additions & 0 deletions docs/architecture/agent-runtime-lifecycle-sequence.md

Large diffs are not rendered by default.

35 changes: 28 additions & 7 deletions scripts/build-web-parallel.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
#!/usr/bin/env node

/**
* Runs the web-ui type-check (tsc --noEmit) and the Vite production build in
* parallel. The two are independent: Vite transpiles with esbuild and never
* consults tsc, so serializing them only added wall-clock time.
* Regenerates TypeScript bindings from the Rust schema, then runs the web-ui
* type-check (tsc --noEmit) and the Vite production build in parallel. The
* latter two are independent: Vite transpiles with esbuild and never consults
* tsc, so serializing them only added wall-clock time.
*
* Both child processes must succeed; if either fails the script exits with a
* non-zero code (after letting the sibling finish so its output is not lost).
* The `gen:types` step (which requires a Rust toolchain) runs serially first so
* the Vite build picks up fresh bindings. Local frontend-only iteration can
* skip it with `pnpm --dir src/web-ui build`. Both child processes must succeed;
* if either fails the script exits with a non-zero code (after letting the
* sibling finish so its output is not lost).
*/

import { spawn } from 'node:child_process';
Expand Down Expand Up @@ -54,13 +58,30 @@ function runPrefixed(prefix, command, args, cwd) {
});
}

// Step 1: regenerate TypeScript bindings from the Rust schema (serial, must
// finish before the Vite build so the frontend picks up fresh types). This is
// the only step that requires a working Rust toolchain; local frontend-only
// iteration can skip it with `pnpm --dir src/web-ui build`.
const genTypesCode = await runPrefixed(
'gen-types',
'pnpm',
['--dir', 'src/web-ui', 'run', 'gen:types'],
ROOT_DIR,
);
if (genTypesCode !== 0) {
process.stderr.write('[build-web-parallel] gen:types failed (see output above)\n');
process.exitCode = 1;
process.exit();
}

// Step 2: type-check and Vite build run in parallel.
const tasks = [
runPrefixed('type-check', 'pnpm', ['run', 'type-check:web'], ROOT_DIR),
runPrefixed('vite-build', 'pnpm', ['--dir', 'src/web-ui', 'build'], ROOT_DIR),
];

const codes = await Promise.all(tasks);
const failed = codes.some((code) => code !== 0);
const buildCodes = await Promise.all(tasks);
const failed = buildCodes.some((code) => code !== 0);
if (failed) {
process.stderr.write('[build-web-parallel] build:web failed (see output above)\n');
}
Expand Down
1 change: 1 addition & 0 deletions scripts/core-boundaries/rules/crate-layout.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const crateLayoutRules = [
{ crateName: 'terminal', layer: 'services', path: 'src/crates/services/terminal' },

{ crateName: 'acp', layer: 'interfaces', path: 'src/crates/interfaces/acp' },
{ crateName: 'app-server', layer: 'interfaces', path: 'src/crates/interfaces/app-server' },
{ crateName: 'sdk-host', layer: 'interfaces', path: 'src/crates/interfaces/sdk-host' },
{ crateName: 'agent-runtime-ipc', layer: 'adapters', path: 'src/crates/adapters/agent-runtime-ipc' },
{ crateName: 'ai-adapters', layer: 'adapters', path: 'src/crates/adapters/ai-adapters' },
Expand Down
11 changes: 11 additions & 0 deletions src/apps/server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ path = "src/main.rs"
[dependencies]
bitfun-core = { path = "../../crates/assembly/core", default-features = false, features = ["product-full"] }

# App-server surface: an in-process JSON-RPC server/client pair over an
# in-memory channel transport. The websocket handler routes agent kernel RPCs
# through this client so agent interfaces uniformly go through app-server.
bitfun-app-server = { path = "../../crates/interfaces/app-server" }
bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime" }
bitfun-events = { path = "../../crates/contracts/events" }

agent-client-protocol = { workspace = true }

# Web framework
axum = { workspace = true }
tower-http = { workspace = true }
Expand All @@ -26,8 +35,10 @@ base64 = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
futures-util = { workspace = true }
futures = { workspace = true }
chrono = { workspace = true }
dirs = { workspace = true }
log = { workspace = true }

[lints]
workspace = true
28 changes: 28 additions & 0 deletions src/apps/server/src/app_server.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//! Server-host app-server wiring: build the in-process `BitfunAppServer` from
//! the product-assembled [`AgentRuntime`] and return a cloneable handle.
//!
//! Under browser-direct ACP-over-WS (Step 2) the server host no longer pairs
//! the app-server with an in-process client over `in_memory_pair`. Instead each
//! WebSocket connection is handed straight to [`BitfunAppServer::serve`] via the
//! [`crate::routes::ws_transport`] `Lines` adapter, so the browser connects
//! directly to the in-process app-server over native JSON-RPC. This module only
//! constructs the [`BitfunAppRuntime`] and wraps it in a [`BitfunAppServer`]
//! (cheap `Clone` via the inner `Arc`); `serve` runs once per WS connection.

use bitfun_agent_runtime::sdk::{AgentEventSource, AgentRuntime};
use bitfun_app_server::{BitfunAppRuntime, BitfunAppServer};

/// Build the in-process `BitfunAppServer` for the Server Host.
///
/// Constructs a [`BitfunAppRuntime`] from the product-assembled `runtime` and
/// its `event_source`, wraps it in a [`BitfunAppServer`] (cheap `Clone`), and
/// returns it. The websocket handler clones this handle once per connection and
/// spawns `serve` on a WS-bridged `Lines` transport.
///
/// The caller must keep the runtime services (coordinator, scheduler, ...) and
/// the `EventQueue` the `event_source` was built from alive for as long as the
/// [`BitfunAppServer`] is in use.
pub(crate) fn build(runtime: AgentRuntime, event_source: AgentEventSource) -> BitfunAppServer {
let app_runtime = BitfunAppRuntime::new(runtime, event_source);
BitfunAppServer::new(app_runtime)
}
18 changes: 13 additions & 5 deletions src/apps/server/src/bootstrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ use std::sync::Arc;
use tokio::sync::RwLock;

/// Shared application state for the server (mirrors Desktop's AppState).
pub struct ServerAppState {
///
/// Several fields are stored to keep the corresponding services alive (they
/// register global singletons during `initialize`), not because they are read
/// again after initialization.
#[allow(dead_code)]
pub(crate) struct ServerAppState {
pub ai_client_factory: Arc<AIClientFactory>,
pub workspace_service: Arc<workspace::WorkspaceService>,
pub workspace_path: Arc<RwLock<Option<std::path::PathBuf>>>,
Expand All @@ -30,7 +35,7 @@ pub struct ServerAppState {
/// Initialize all core services and return the shared server state.
///
/// The optional `workspace` path, when provided, is opened automatically.
pub async fn initialize(workspace: Option<String>) -> anyhow::Result<Arc<ServerAppState>> {
pub(crate) async fn initialize(workspace: Option<String>) -> anyhow::Result<Arc<ServerAppState>> {
log::info!("Initializing BitFun server core services");

// 1. Global config
Expand Down Expand Up @@ -137,9 +142,12 @@ pub async fn initialize(workspace: Option<String>) -> anyhow::Result<Arc<ServerA
coordination::set_global_scheduler(scheduler.clone());

// Cron service
let cron_service =
bitfun_core::service::cron::CronService::new(path_manager.clone(), scheduler.clone())
.await?;
let cron_service = bitfun_core::service::cron::CronService::new(
path_manager.clone(),
coordinator.clone(),
scheduler.clone(),
)
.await?;
bitfun_core::service::cron::set_global_cron_service(cron_service.clone());
let cron_subscriber = Arc::new(bitfun_core::service::cron::CronEventSubscriber::new(
cron_service.clone(),
Expand Down
54 changes: 54 additions & 0 deletions src/apps/server/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
// The app-server's `Builder::on_receive_request(...)` chain monomorphizes
// into a deeply nested `ChainedHandler<ChainedHandler<…>>` (~20 request
// handlers plus a dispatch handler). Computing the resulting connection
// future's layout pushes the trait solver past the default 128 limit, so bump
// it. Matches the compiler's own suggestion in the overflow diagnostic.
#![recursion_limit = "256"]

use anyhow::Result;
/// BitFun Server
///
Expand All @@ -15,6 +22,8 @@ use serde::Serialize;
use std::{collections::HashSet, net::SocketAddr, path::PathBuf, sync::Arc};
use tower_http::cors::CorsLayer;

mod app_server;
mod bootstrap;
mod routes;

pub(crate) struct DispatchHostState {
Expand All @@ -25,6 +34,10 @@ pub(crate) struct DispatchHostState {
/// Application state
#[derive(Clone)]
pub struct AppState {
// NOTE(Step 2a): only read by the external_sources dispatch path, which is
// temporarily dead under browser-direct ACP-over-WS. Kept for the follow-up
// that brings external_sources onto the app-server schema.
#[allow(dead_code)]
external_workspace_root: Option<PathBuf>,
allowed_browser_origins: Arc<HashSet<String>>,
dispatch_host: Option<Arc<DispatchHostState>>,
Expand Down Expand Up @@ -82,6 +95,43 @@ async fn main() -> Result<()> {
.map_err(|error| anyhow::anyhow!("Could not open Server workspace: {error}"))
})
.transpose()?;

// Initialize the full agentic stack (coordinator, scheduler, token usage,
// MCP/config/filesystem services, event queue). This binding is held alive
// for the lifetime of the server so its services outlive every websocket
// connection; the app-server client and spawned tasks hold their own Arc
// clones of the coordinator, scheduler, and event queue.
let server_state = bootstrap::initialize(
external_workspace_root
.as_ref()
.map(|path| path.to_string_lossy().into_owned()),
)
.await?;

// Build the agent runtime the same way the Desktop session application does,
// then build an in-process `BitfunAppServer` for it. Each WebSocket
// connection is handed straight to `BitfunAppServer::serve` over a WS-bridged
// `Lines` transport (browser-direct ACP-over-WS, Step 2), so the browser
// connects directly to the in-process app-server over native JSON-RPC — no
// shared in-process client, no custom WS envelope.
let agent_runtime =
bitfun_core::product_runtime::CoreProductAgentRuntime::build_session_surface(
server_state.coordinator.clone(),
server_state.scheduler.clone(),
server_state.token_usage_service.clone(),
)
.map_err(|error| anyhow::anyhow!("Failed to build agent runtime: {error}"))?;
// The event source wraps the same `EventQueue` the coordinator publishes to;
// each connection's `serve` main loop subscribes independently and projects
// runtime events to the frontend shape before pushing them to the browser.
let event_source =
bitfun_agent_runtime::sdk::AgentEventSource::new(server_state.event_queue.clone());
let bitfun_app_server = app_server::build(agent_runtime, event_source);

tracing::info!(
"App-server ready; each WebSocket connection drives one in-process serve over native JSON-RPC"
);

let configured_origins = if args.allowed_origins.is_empty() {
DEFAULT_ALLOWED_BROWSER_ORIGINS
.iter()
Expand Down Expand Up @@ -139,6 +189,10 @@ async fn main() -> Result<()> {
.allow_methods([Method::GET])
.allow_origin(cors_origins),
)
// The BitFunAppServer is cloned per WebSocket connection through an axum
// Extension (cheap Arc clone); each connection spawns its own `serve`
// over a WS-bridged `Lines` transport.
.layer(axum::Extension(bitfun_app_server))
.with_state(app_state);

let addr = SocketAddr::from(([127, 0, 0, 1], 8080));
Expand Down
13 changes: 13 additions & 0 deletions src/apps/server/src/routes/external_sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ use std::path::PathBuf;

use crate::AppState;

// NOTE(Step 2a): these host-local external-source dispatch helpers were wired
// through the old `websocket.rs::handle_command` path. Under browser-direct
// ACP-over-WS the browser connects straight to the in-process app-server, so
// `external_sources` commands now hit the ACP `method_not_found` fallback
// (the desktop/Server Host external-source surface is temporarily unavailable
// in web mode -- tracked for a later batch that brings them onto the app-server
// schema). Kept here so the host capability plumbing stays intact for that
// follow-up; silenced as dead code in the meantime.

#[allow(dead_code)]
pub(crate) fn supports(method: &str) -> bool {
matches!(
method,
Expand All @@ -25,6 +35,7 @@ pub(crate) fn supports(method: &str) -> bool {
)
}

#[allow(dead_code)]
pub(crate) async fn dispatch(
method: &str,
params: serde_json::Value,
Expand Down Expand Up @@ -76,6 +87,7 @@ pub(crate) async fn dispatch(
})
}

#[allow(dead_code)]
fn external_workspace_root(
state: &AppState,
request: &serde_json::Value,
Expand Down Expand Up @@ -119,6 +131,7 @@ fn external_workspace_root(
Ok(Some(requested))
}

#[allow(dead_code)]
fn optional_bool_field(
request: &serde_json::Value,
key: &str,
Expand Down
1 change: 1 addition & 0 deletions src/apps/server/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ pub(crate) mod external_sources;
///
/// Contains all HTTP and WebSocket routes
pub(crate) mod websocket;
pub(crate) mod ws_transport;
Loading
Loading