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
4 changes: 4 additions & 0 deletions crates/codeoid-client/src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,8 @@ fn daemon_kind(msg: &DaemonMessage) -> &'static str {
DaemonMessage::SettingsSchemaResult { .. } => "settings.schema.result",
DaemonMessage::SettingsGetResult { .. } => "settings.get.result",
DaemonMessage::SettingsSetResult { .. } => "settings.set.result",
DaemonMessage::FleetSnapshotResult { .. } => "fleet.snapshot.result",
DaemonMessage::FleetUpdate { .. } => "fleet.update",
DaemonMessage::Unknown => "unknown",
}
}
Expand Down Expand Up @@ -569,6 +571,8 @@ fn client_kind(msg: &ClientMessage) -> &'static str {
ClientMessage::SettingsSchema { .. } => "settings.schema",
ClientMessage::SettingsGet { .. } => "settings.get",
ClientMessage::SettingsSet { .. } => "settings.set",
ClientMessage::FleetSubscribe { .. } => "fleet.subscribe",
ClientMessage::FleetUnsubscribe { .. } => "fleet.unsubscribe",
}
}

Expand Down
26 changes: 25 additions & 1 deletion crates/codeoid-protocol/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,28 @@ pub enum ClientMessage {
id: String,
patches: Vec<SettingPatch>,
},

/// Subscribe to the fleet board: answered with `fleet.snapshot.result`,
/// then streamed `fleet.update` deltas until `fleet.unsubscribe` or the
/// socket drops. Gated on the `fleet:read` scope.
#[serde(rename = "fleet.subscribe", rename_all = "camelCase")]
FleetSubscribe {
id: String,
/// Only `Tenant` today — the caller's own account+project board.
scope: FleetScope,
},

/// Stop the delta stream without dropping the connection.
#[serde(rename = "fleet.unsubscribe", rename_all = "camelCase")]
FleetUnsubscribe { id: String },
}

/// Breadth of a fleet subscription. Closed on purpose: widening it is an
/// explicit protocol change on both sides, not something a client can ask for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FleetScope {
Tenant,
}

/// One change requested by `settings.set`, addressed by field `key`. The
Expand Down Expand Up @@ -326,7 +348,9 @@ impl ClientMessage {
| Self::SessionImport { id, .. }
| Self::SettingsSchema { id }
| Self::SettingsGet { id }
| Self::SettingsSet { id, .. } => id,
| Self::SettingsSet { id, .. }
| Self::FleetSubscribe { id, .. }
| Self::FleetUnsubscribe { id } => id,
}
}
}
Expand Down
141 changes: 141 additions & 0 deletions crates/codeoid-protocol/src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,11 +200,152 @@ pub enum DaemonMessage {
restart_required: bool,
},

/// Reply to `fleet.subscribe` — the whole board in one payload.
#[serde(rename = "fleet.snapshot.result", rename_all = "camelCase")]
FleetSnapshotResult {
request_id: String,
fleet: FleetSnapshot,
},

/// One incremental board change, pushed to subscribed clients.
#[serde(rename = "fleet.update", rename_all = "camelCase")]
FleetUpdate { delta: FleetDelta },

/// Forward-compat sink. Preserves raw JSON so the TUI can log it.
#[serde(other)]
Unknown,
}

// ── Fleet board (mirrors codeoid/packages/protocol types.ts) ─────────────────

/// A dispatch task as the board draws it.
///
/// Note what is NOT here: the dispatch `prompt` and the worker `workdir`. The
/// daemon deliberately withholds them — the board renders lifecycle, and the
/// prompt is the one field on a task row carrying arbitrary user text.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FleetTask {
pub id: String,
pub kind: FleetTaskKind,
pub shape: FleetTaskShape,
pub status: FleetTaskStatus,
pub attempts: u32,
/// Epoch ms.
pub created_at: i64,
/// spawn: the worker session this task created. Joins to `FleetSnapshot::workers`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_session_id: Option<String>,
/// send: the existing session this task was routed to. Same join.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_session: Option<String>,
/// Compressed result — never a raw transcript.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result_digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
/// Conductor WIMSE URI — who dispatched this.
pub created_by: String,
/// Dispatch group (fan-out barrier); absent = standalone.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group_id: Option<String>,
/// RESERVED — never populated by the daemon today. Present so typed
/// fan-in edges are a later non-breaking add.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub depends_on: Option<Vec<String>>,
}

/// `#[serde(other)]` throughout: a daemon newer than this client must degrade
/// to an unrendered node, never fail the whole board's deserialization.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FleetTaskKind {
Send,
Spawn,
#[serde(other)]
Unknown,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FleetTaskShape {
/// Deliver a change.
Ship,
/// Investigate and report; never pushes.
Scout,
#[serde(other)]
Unknown,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FleetTaskStatus {
Queued,
Claimed,
Running,
Done,
Failed,
/// The failure cap tripped — needs a human, will not retry itself.
Blocked,
#[serde(other)]
Unknown,
}

/// A dispatch lifecycle event — the audit trail behind the board.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FleetEvent {
pub id: i64,
pub task_id: String,
#[serde(rename = "type")]
pub event_type: String,
pub digest: String,
/// Epoch ms.
pub created_at: i64,
}

/// Fleet-wide rollup, normalized across backends.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FleetUsage {
pub active_tasks: u32,
pub blocked_tasks: u32,
pub input_tokens: u64,
pub output_tokens: u64,
pub total_cost_usd: f64,
}

/// Everything needed to draw the fleet, in one payload.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FleetSnapshot {
/// Absent when the tenant has no conductor — a valid, common state.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conductor: Option<SessionInfo>,
/// Sessions the board references — spawned workers and dispatch targets.
#[serde(default)]
pub workers: Vec<SessionInfo>,
/// Newest first.
#[serde(default)]
pub tasks: Vec<FleetTask>,
/// Newest first.
#[serde(default)]
pub events: Vec<FleetEvent>,
#[serde(default)]
pub agg: FleetUsage,
}

/// One incremental board change. Carries the FULL row rather than a patch, so a
/// client that missed a delta still converges and re-delivery is idempotent.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum FleetDelta {
#[serde(rename_all = "camelCase")]
Task { task: FleetTask, agg: FleetUsage },
#[serde(rename_all = "camelCase")]
Event { event: FleetEvent, agg: FleetUsage },
}

// ── Settings manifest + snapshot (mirrors codeoid/packages/protocol settings.ts) ──

/// The declarative settings manifest served over `settings.schema`. Rendered
Expand Down
17 changes: 10 additions & 7 deletions crates/codeoid-protocol/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,22 +38,25 @@ pub mod session;
pub mod tool;

pub use client::{
Attachment, ClientMessage, SearchScope, SendPriority, SessionImportSource, SettingPatch,
Attachment, ClientMessage, FleetScope, SearchScope, SendPriority, SessionImportSource,
SettingPatch,
};
pub use daemon::{
AuthOkMsg, ClaudeConfigAgent, ClaudeConfigHook, ClaudeConfigMcpServer, ClaudeConfigScope,
ClaudeConfigSkill, DaemonMessage, ErrorCode, McpServerStatus, ModelInfo, ProviderCommand,
SecretStatus, SessionExportCounts, SessionExportManifest, SessionExportMetaSlim,
SessionExportPayload, SessionExportWorkdir, SessionSearchHit, SessionSearchSnippet,
SessionUiRequestMsg, SettingError, SettingField, SettingOption, SettingState, SettingsGroup,
SettingsManifest, SettingsSnapshot, SettingsTab, UiRequestMethod, UiResolvedReason,
ClaudeConfigSkill, DaemonMessage, ErrorCode, FleetDelta, FleetEvent, FleetSnapshot, FleetTask,
FleetTaskKind, FleetTaskShape, FleetTaskStatus, FleetUsage, McpServerStatus, ModelInfo,
ProviderCommand, SecretStatus, SessionExportCounts, SessionExportManifest,
SessionExportMetaSlim, SessionExportPayload, SessionExportWorkdir, SessionSearchHit,
SessionSearchSnippet, SessionUiRequestMsg, SettingError, SettingField, SettingOption,
SettingState, SettingsGroup, SettingsManifest, SettingsSnapshot, SettingsTab, UiRequestMethod,
UiResolvedReason,
};
pub use message::{
ContentPart, IdentityType, MessageIdentity, MessageRole, SessionMessage, SessionMessageDelta,
};
pub use session::{
CollaborationConfig, CollaborationRole, CollaborationRoleRef, ForkedFrom, SessionInfo,
SessionMode, SessionStatus, SessionUsage, SessionWorktree, Subagent, TurnUsage,
SessionMode, SessionRole, SessionStatus, SessionUsage, SessionWorktree, Subagent, TurnUsage,
};
pub use tool::{CancelReason, ConfirmedBy, ToolInfo, ToolPhase, ToolState};

Expand Down
52 changes: 52 additions & 0 deletions crates/codeoid-protocol/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ pub struct SessionInfo {
pub status: SessionStatus,
pub created_by: String,
pub created_at: String,
/// Last time this session changed state — a turn started, a tool ran, it
/// went idle. The ordering key for a relevance-sorted session list; the
/// daemon has always tracked it (it orders the resumed list by it) and now
/// puts it on the wire.
///
/// Absent from a daemon that predates the field: fall back to `created_at`
/// rather than treating it as "never active", which would sink every
/// session to the bottom.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_activity_at: Option<String>,
pub attached_clients: u32,

#[serde(default, skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -107,6 +117,33 @@ pub struct SessionInfo {
/// which marks the orchestrating parent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub collaboration_role: Option<CollaborationRoleRef>,

/// What this session IS in the fleet: the tenant's conductor, a
/// dispatch-spawned worker, or — when absent — an ordinary session.
///
/// The daemon has carried this on the wire since the conductor shipped;
/// this crate simply never modelled it, so the TUI could not so much as
/// badge a conductor. Required by the fleet board
/// (docs/conductor-frontends-design.md §10–§11).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<SessionRole>,
}

/// A session's place in the fleet.
///
/// `#[serde(other)]` on `Unknown` is load-bearing: this is a client talking to
/// a daemon that may be NEWER than it. A future role (a domain sub-conductor,
/// say) must degrade to "some role I don't render" rather than fail to
/// deserialize the whole `SessionInfo` and blank the session list.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SessionRole {
/// The per-tenant fleet supervisor.
Conductor,
/// A disposable worker created by a dispatch.
Worker,
#[serde(other)]
Unknown,
}

/// Where a forked session came from — the parent id, the parent's name at
Expand Down Expand Up @@ -162,6 +199,21 @@ pub struct CollaborationRole {
/// to. Write authority is opt-in per role.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub write: Option<bool>,
/// Goal-blackboard artifact kinds this role may READ — `spec`, `research`,
/// `adr`, `task-list`, `diff`, `findings`, or `extra/<key>`.
///
/// `None` = the daemon's default profile for this role name; a role with no
/// profile and no declaration reads nothing. This is what makes reviewer
/// independence structural: `review` reads `diff`+`spec` and NOT
/// `research` or its peers' `findings`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reads: Option<Vec<String>>,
/// Artifact kinds this role may WRITE. `None` = the default profile for
/// this role name. A role writing a multi-writer kind (`findings`) writes
/// into its own slot, chosen daemon-side, so one reviewer can never
/// overwrite another's.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub writes: Option<Vec<String>>,
}

/// Set on a role-CHILD of a collaborative session: which collaboration it
Expand Down
Loading
Loading