refactor(claw): assemble the daemon from an extension set

The daemon named every feature twice: once in the interactive
handler's sixteen-argument constructor, once across the source list,
the tick, and the boot pass. Both assemblies are now loops over one
ClawExtension trait, so an integration is a struct plus a line in
the standard set. The chain order, the detect order, and the boot
order are unchanged; the surface suite pins them. Each extension
struct is the boundary a later move into the integration crates
would lift wholesale.
This commit is contained in:
vhaudiquet committed 2026-10-20 05:39:57 +02:00
1 parent 8ab513d49f
commit 5fdd17b901
5 files changed
+758 -260

No files matched your search

+5
View File
@@ -111,6 +111,11 @@ pub enum ForgeKind {
}
impl ForgeKind {
/// Every kind the claw ships a source for, in registration order.
/// A kind missing here has no watch poller, whatever the table
/// holds.
pub const ALL: [ForgeKind; 3] = [ForgeKind::GitHub, ForgeKind::Gitea, ForgeKind::GitLab];
/// The source name this kind registers and dispatches under.
pub fn name(self) -> &'static str {
match self {
+55 -74
View File
@@ -30,13 +30,11 @@ use rhema_rsd::{
SystemClock,
};
use crate::calendar::CalendarSource;
use crate::config::{ClawConfig, ConfigError, ForgeKind};
use crate::forge::ForgeSource;
use crate::config::{ClawConfig, ConfigError};
use crate::extension::{ExtensionDeps, Extensions, Lanes};
use crate::handler::ClawHandler;
use crate::http::ClawState;
use crate::notifications::NotificationStore;
use crate::task_source::TaskScheduler;
use crate::task_store::TaskStore;
use rhema_memory::FileMemoryStore;
@@ -125,15 +123,16 @@ fn interactive_dispatch() -> DispatcherConfig {
}
}
/// The resident daemon: one source per forge kind, the calendar
/// reminders, the task scheduler, the coding-job source, the three
/// drivers, and the HTTP surface.
/// The resident daemon: the extension set's sources (the forge watches,
/// the task scheduler, the coding jobs, the calendar reminders), the
/// three drivers, and the HTTP surface.
pub struct Daemon {
sources: Vec<ForgeSource>,
calendar_source: CalendarSource,
scheduler: TaskScheduler,
job_source: crate::jobs::JobsSource,
jobs: Arc<crate::job_store::CodingJobStore>,
/// The extension set: the boot pass runs its hooks, and the tick
/// detects through the sources collected from it at assembly.
extensions: Arc<Extensions>,
/// The set's sources, in set order, each registered on the lane it
/// feeds; built once, at assembly.
sources: Vec<Box<dyn Source>>,
forge_dispatcher: Arc<Dispatcher>,
interactive_dispatcher: Arc<Dispatcher>,
jobs_dispatcher: Arc<Dispatcher>,
@@ -183,6 +182,11 @@ impl Daemon {
let notifications = Arc::new(NotificationStore::open(&db)?);
let tasks = Arc::new(TaskStore::open(&db)?);
let turns = Arc::new(crate::turn_store::TurnStore::open(&db)?);
// The hub replays what past turns streamed: every event appends
// to the turn-history table as it streams, so a restart's replay
// reads the table back — a restart continues the transcript,
// whole, with no boot-time seed pass.
let hub = Arc::new(EventHub::with_record_store(turns.clone()));
// The coding-job tables: instances the `coding_agent` tool
// drives, jobs the jobs dispatcher answers.
let jobs = Arc::new(crate::job_store::CodingJobStore::open(&db)?);
@@ -221,23 +225,6 @@ impl Daemon {
.map_err(|err| ClawError::Backend(err.to_string()))?,
);
// One source per known forge kind, whatever the table currently
// holds: a watch added later (chat tool, HTTP route) of a kind
// nobody watched at startup still has its key function
// registered. A source over zero watches detects nothing.
let sources = [ForgeKind::GitHub, ForgeKind::Gitea, ForgeKind::GitLab]
.into_iter()
.map(|kind| {
ForgeSource::new(
&forge_dispatcher,
kind.name(),
watches.clone(),
accounts.clone(),
notifications.clone(),
)
})
.collect();
// The forge handler resolves its watches through the table at
// dispatch time, so a watch added mid-run is answerable without a
// restart.
@@ -260,11 +247,6 @@ impl Daemon {
})),
);
// The hub replays what past turns streamed: every event appends
// to the turn-history table as it streams, so a restart's replay
// reads the table back — a restart continues the transcript,
// whole, with no boot-time seed pass.
let hub = Arc::new(EventHub::with_record_store(turns.clone()));
// The consent table and its wake bridge: the `ask_secret` tool
// asks through them, the consent routes answer through them.
let consents = Arc::new(crate::consents::ConsentStore::open(&db)?);
@@ -306,23 +288,44 @@ impl Daemon {
// at every boot by deterministic discovery, never by the model,
// and regenerated over whatever the last boot wrote.
crate::environment::write_report(config.bootstrap_dir()?.join("ENVIRONMENT.md"))?;
// The extension set: every resident feature beyond the core, one
// struct per integration. The handler's tool chain, the tick's
// detect passes, and the boot pass below all iterate it — the
// daemon names no feature twice.
let extensions = Arc::new(Extensions::standard(ExtensionDeps {
watches: watches.clone(),
accounts: accounts.clone(),
notifications: notifications.clone(),
tasks: tasks.clone(),
jobs: jobs.clone(),
consents: consents.clone(),
bridge: bridge.clone(),
attachments: attachments.clone(),
phone: phone.clone(),
identity: identity.clone(),
hub: hub.clone(),
workspace: interactive_workspace.clone(),
calendars: config.calendars.clone(),
}));
eprintln!("[claw] extensions: {}", extensions.names().join(", "));
// The sources register on their lanes as they build: a watch of
// a kind nobody watched at startup still has its key function
// registered, and a source over zero watches detects nothing.
let lanes = Lanes {
forge: forge_dispatcher.clone(),
interactive: interactive_dispatcher.clone(),
jobs: jobs_dispatcher.clone(),
};
let sources = extensions.sources(&lanes)?;
let interactive_handler = InteractiveHandler::new(
backends.clone(),
interactive_workspace,
watches.clone(),
memory,
bootstrap.clone(),
hub.clone(),
notifications.clone(),
tasks.clone(),
jobs.clone(),
accounts.clone(),
config.max_turn_steps,
consents.clone(),
bridge.clone(),
attachments.clone(),
phone.clone(),
identity.clone(),
extensions.clone(),
);
let interactive_driver = Arc::new(
Driver::new(
@@ -350,24 +353,7 @@ impl Daemon {
crate::jobs::is_job_key(key.as_str())
})),
);
let job_source = crate::jobs::JobsSource::new(&jobs_dispatcher, jobs.clone());
crate::interactive::register_user(&interactive_dispatcher);
// The scheduler is the resident dispatcher's second own source:
// its detect pass (the daemon's tick drives it) is what turns a
// due task row into an event, never a timer near the model.
let scheduler = TaskScheduler::new(
&interactive_dispatcher,
Arc::new(SystemClock),
tasks.clone(),
);
// The calendar reminders join them: a due reminder is an
// event, never a timer near the model.
let calendar_source = CalendarSource::new(
&interactive_dispatcher,
Arc::new(SystemClock),
&config.calendars,
)
.map_err(ClawError::Backend)?;
let http = ClawState::new(
interactive_dispatcher.clone(),
@@ -389,11 +375,8 @@ impl Daemon {
);
Ok(Self {
extensions,
sources,
calendar_source,
scheduler,
job_source,
jobs,
forge_dispatcher,
interactive_dispatcher,
jobs_dispatcher,
@@ -435,13 +418,10 @@ impl Daemon {
/// top of the next dispatch (safe tools re-run, the rest report
/// interrupted). Idempotent; called before the first pass.
pub async fn bootstrap(&self) -> Result<(), ClawError> {
// The job rows first: a turn the restart cut is failed
// interrupted before anything re-runs, and a still-queued row
// is re-armed with a fresh dedup id.
let touched = self.jobs.recover()?;
if touched > 0 {
eprintln!("[claw] jobs: {touched} row(s) recovered at boot");
}
// The extension boot hooks first: a coding job the restart cut
// is failed interrupted before anything re-runs, and a
// still-queued row is re-armed with a fresh dedup id.
self.extensions.boot()?;
self.forge_dispatcher.recover_all()?;
self.jobs_dispatcher.recover_all()?;
self.interactive_dispatcher.recover_all()?;
@@ -454,9 +434,10 @@ impl Daemon {
/// backstop — a message delivered live into a running turn waits at
/// the key for a pass like this one.
pub async fn tick(&self) -> Result<TickReport, ClawError> {
let mut detected = self.scheduler.detect().await?;
detected += self.calendar_source.detect().await?;
detected += self.job_source.detect().await?;
// The tick only detects — every timer here drives
// deterministic detection, and the set's order is the contract:
// due tasks before the calendar reminders that fire for them.
let mut detected = 0usize;
for source in &self.sources {
detected += source.detect().await?;
}
+494
View File
@@ -0,0 +1,494 @@
//! The extension seam: one trait for everything the resident runs
//! beyond its core, and the standard set the daemon assembles from it.
//!
//! The claw's core is small: the shell, memory, the bootstrap prompt,
//! the chat surface, and the dispatch lanes. Everything else — the
//! watch table, the task scheduler, the coding jobs, the notification
//! queue, the consent bridge, attachments, the phone, the accounts
//! layer, the presentation identity, the calendar reminders — joins
//! the daemon through [`ClawExtension`]: tool resolvers for the
//! resident's chain, sources for the tick's detect passes, and a boot
//! hook where a previous process's wreckage gets made honest. The
//! daemon's assembly loops over the set; it does not name features.
//!
//! The seam is compile-time, not dynamic. An extension is a struct in
//! this crate holding its own store handles; installing one is a
//! constructor line in [`Extensions::standard`], and each struct is
//! the boundary a future extraction would lift wholesale — the glue
//! next to its logic in an integration crate, or, if a second
//! deployment shape ever appears, an out-of-process plugin speaking
//! for the struct. The HTTP routes are deliberately not part of the
//! trait: the surface is one API contract the web UI and the phone
//! client both compile against, and it stays whole in [`crate::http`]
//! until a second consumer exists.
//!
//! Order is contract. The resolver chain is the extensions'
//! [`ClawExtension::tools`] in [`Extensions::standard`]'s order, and
//! the tick detects through [`ClawExtension::sources`] in the same
//! order — due tasks before calendar reminders, the toolsets the model
//! addresses most first. Reordering the set changes what the model
//! sees and what fires first.
use std::path::PathBuf;
use std::sync::Arc;
use rhema_autonomous_agent::{Allowlist, Source, ToolRegistry};
use rhema_core::ToolResolver;
use rhema_rsd::{Dispatcher, ResourceKey, SystemClock};
use crate::calendar::CalendarSource;
use crate::config::{CalendarWatch, ForgeKind};
use crate::consents::WaitBridge;
use crate::daemon::ClawError;
use crate::forge::ForgeSource;
use crate::hub::EventHub;
/// The store handles the standard set is assembled over. One struct,
/// so a new extension is a field plus a constructor line, not another
/// positional argument down a sixteen-deep call chain.
pub struct ExtensionDeps {
/// The watch table: the forge extension's tools manage it, its
/// sources poll it, and the accounts tools hang account watches
/// off it.
pub watches: Arc<crate::watch_store::ClawStore>,
/// The accounts layer the accounts tools manage and the forge
/// sources poll through.
pub accounts: Arc<dyn rhema_identity::IdentityStore>,
/// The notification queue: the `notify` tool writes it, devices
/// poll it, and non-chat replies auto-notify through it.
pub notifications: Arc<crate::notifications::NotificationStore>,
/// The task table the task tools manage and the scheduler fires.
pub tasks: Arc<crate::task_store::TaskStore>,
/// The coding-job tables the `coding_agent` tool enqueues into.
pub jobs: Arc<crate::job_store::CodingJobStore>,
/// The consent table the `ask_secret` tool asks through.
pub consents: Arc<crate::consents::ConsentStore>,
/// The wake bridge between the consent tools and the routes that
/// answer them.
pub bridge: Arc<WaitBridge>,
/// The attachment store the `attach_file` tool copies into.
pub attachments: Arc<crate::attachments::AttachmentStore>,
/// The phone tables the `phone` tool reads and queues through.
pub phone: Arc<crate::phone::PhoneStore>,
/// The presentation-identity store the `identity_set` tool writes.
pub identity: Arc<crate::identity::IdentityStore>,
/// The event hub: the key-scoped tools (consents, attachments,
/// phone) emit their cards and asks onto the turn it streams.
pub hub: Arc<EventHub>,
/// The interactive workspace the account exec and the identity
/// registry resolve against.
pub workspace: PathBuf,
/// The calendar feeds the reminders source polls.
pub calendars: Vec<CalendarWatch>,
}
/// The dispatch lanes a source can feed, handed to
/// [`ClawExtension::sources`] once the daemon has built its
/// dispatchers. Which lane an extension's sources feed is fixed by
/// what the extension reads here — the forge sources feed the forge
/// lane, the task scheduler and the calendar reminders the resident
/// lane, the coding jobs their own.
pub struct Lanes {
/// The forge lane: watch polls and account notifications.
pub forge: Arc<Dispatcher>,
/// The resident lane: chat, tasks, reminders, ingest.
pub interactive: Arc<Dispatcher>,
/// The coding-job lane: one leased turn per job.
pub jobs: Arc<Dispatcher>,
}
/// One resident feature beyond the core: the tools it lends the
/// resolver chain, the sources it feeds the tick, and the boot hook
/// that recovers what a crash left behind.
///
/// Every method has a default, so an extension contributes only what
/// it has; `tools` is called per dispatch and `sources` once at
/// assembly.
pub trait ClawExtension: Send + Sync {
/// The extension's name, as logs and the boot line spell it.
fn name(&self) -> &'static str;
/// The tool resolvers this extension lends the resident's chain,
/// in the order they join it. Each is cut permissive — the surface
/// is the trust boundary — and rebuilt per dispatch, because the
/// key-scoped toolsets (consents, attachments, phone) must bind
/// their asks and cards to the session that asked.
fn tools(&self, _key: &ResourceKey) -> Vec<Arc<dyn ToolResolver>> {
Vec::new()
}
/// The sources this extension feeds the tick with, each already
/// registered on the lane it feeds. Built once, at assembly; a
/// source over an empty table simply detects nothing.
fn sources(&self, _lanes: &Lanes) -> Result<Vec<Box<dyn Source>>, ClawError> {
Ok(Vec::new())
}
/// The boot pass: whatever a previous process's crash left
/// inconsistent in this extension's tables, made honest before the
/// first tick. Runs before the dispatchers recover their leases.
fn boot(&self) -> Result<(), ClawError> {
Ok(())
}
}
/// The assembled set: the extension list the daemon iterates.
pub struct Extensions {
items: Vec<Box<dyn ClawExtension>>,
}
impl Extensions {
/// The standard set, in the order the resolver chain and the tick
/// inherit: notifications, tasks, jobs, forge watches, accounts,
/// identity, consents, attachments, phone, calendar reminders.
/// Tasks precede the calendar on purpose — a due task is the
/// user-facing clock, and the reminder that fires for it should
/// find the work already detected.
pub fn standard(deps: ExtensionDeps) -> Self {
Self {
items: vec![
Box::new(NotifyExtension {
notifications: deps.notifications.clone(),
}),
Box::new(TasksExtension {
tasks: deps.tasks.clone(),
}),
Box::new(JobsExtension {
jobs: deps.jobs.clone(),
}),
Box::new(ForgeExtension {
watches: deps.watches.clone(),
accounts: deps.accounts.clone(),
notifications: deps.notifications.clone(),
}),
Box::new(AccountsExtension {
accounts: deps.accounts.clone(),
watches: deps.watches.clone(),
workspace: deps.workspace.clone(),
}),
Box::new(IdentityExtension {
identity: deps.identity.clone(),
workspace: deps.workspace.clone(),
}),
Box::new(ConsentsExtension {
consents: deps.consents.clone(),
bridge: deps.bridge.clone(),
hub: deps.hub.clone(),
}),
Box::new(AttachmentsExtension {
store: deps.attachments.clone(),
workspace: deps.workspace.clone(),
hub: deps.hub.clone(),
}),
Box::new(PhoneExtension {
store: deps.phone.clone(),
consents: deps.consents.clone(),
bridge: deps.bridge.clone(),
hub: deps.hub.clone(),
}),
Box::new(CalendarExtension {
calendars: deps.calendars,
}),
],
}
}
/// The full resolver chain one dispatch runs under: every
/// extension's tools, in set order, key-scoped toolsets bound to
/// `key`.
pub fn tools(&self, key: &ResourceKey) -> Vec<Arc<dyn ToolResolver>> {
self.items
.iter()
.flat_map(|extension| extension.tools(key))
.collect()
}
/// Every extension's sources, in set order, each registered on the
/// lane it feeds. Built once; the daemon holds the result for the
/// tick's detect passes.
pub fn sources(&self, lanes: &Lanes) -> Result<Vec<Box<dyn Source>>, ClawError> {
let mut sources = Vec::new();
for extension in &self.items {
sources.extend(extension.sources(lanes)?);
}
Ok(sources)
}
/// Every extension's boot pass, in set order.
pub fn boot(&self) -> Result<(), ClawError> {
for extension in &self.items {
extension.boot()?;
}
Ok(())
}
/// The set's names, the order the chain and the tick run in — the
/// boot line's inventory.
pub fn names(&self) -> Vec<&'static str> {
self.items
.iter()
.map(|extension| extension.name())
.collect()
}
}
/// Wraps a registry in the permissive allowlist — the resident tier's
/// standing policy: the surface is the trust boundary, and the shell
/// already runs auto-approved.
fn permissive(registry: ToolRegistry) -> Arc<dyn ToolResolver> {
registry.resolver_for(&Allowlist::permissive())
}
/// The notification queue: the `notify` tool the resident writes
/// through and the rows a device polls and acks. Nothing detects —
/// outbound mail needs no polling.
struct NotifyExtension {
notifications: Arc<crate::notifications::NotificationStore>,
}
impl ClawExtension for NotifyExtension {
fn name(&self) -> &'static str {
"notify"
}
fn tools(&self, _key: &ResourceKey) -> Vec<Arc<dyn ToolResolver>> {
vec![permissive(crate::notify_tools::registry(
self.notifications.clone(),
))]
}
}
/// The task scheduler: the `schedule_task`/`list_tasks`/`cancel_task`
/// tools, and the source whose detect pass turns a due row into an
/// event on the resident lane — never a timer near the model.
struct TasksExtension {
tasks: Arc<crate::task_store::TaskStore>,
}
impl ClawExtension for TasksExtension {
fn name(&self) -> &'static str {
"tasks"
}
fn tools(&self, _key: &ResourceKey) -> Vec<Arc<dyn ToolResolver>> {
vec![permissive(crate::task_tools::registry(self.tasks.clone()))]
}
fn sources(&self, lanes: &Lanes) -> Result<Vec<Box<dyn Source>>, ClawError> {
Ok(vec![Box::new(crate::task_source::TaskScheduler::new(
&lanes.interactive,
Arc::new(SystemClock),
self.tasks.clone(),
))])
}
}
/// The coding jobs: the `coding_agent` tool, the source that ingests
/// queued rows onto the jobs lane, and the boot pass that fails the
/// turns a crash interrupted.
struct JobsExtension {
jobs: Arc<crate::job_store::CodingJobStore>,
}
impl ClawExtension for JobsExtension {
fn name(&self) -> &'static str {
"jobs"
}
fn tools(&self, _key: &ResourceKey) -> Vec<Arc<dyn ToolResolver>> {
vec![permissive(crate::job_tools::registry(self.jobs.clone()))]
}
fn sources(&self, lanes: &Lanes) -> Result<Vec<Box<dyn Source>>, ClawError> {
Ok(vec![Box::new(crate::jobs::JobsSource::new(
&lanes.jobs,
self.jobs.clone(),
))])
}
fn boot(&self) -> Result<(), ClawError> {
let touched = self.jobs.recover().map_err(ClawError::Jobs)?;
if touched > 0 {
eprintln!("[claw] jobs: {touched} row(s) recovered at boot");
}
Ok(())
}
}
/// The forge watches: the watch-management tools the resident configures
/// on request, and one source per forge kind — repo watches polled
/// through the watch table, account notifications triaged through the
/// accounts layer.
struct ForgeExtension {
watches: Arc<crate::watch_store::ClawStore>,
accounts: Arc<dyn rhema_identity::IdentityStore>,
notifications: Arc<crate::notifications::NotificationStore>,
}
impl ClawExtension for ForgeExtension {
fn name(&self) -> &'static str {
"forge"
}
fn tools(&self, _key: &ResourceKey) -> Vec<Arc<dyn ToolResolver>> {
vec![permissive(crate::watch_tools::registry(
self.watches.clone(),
))]
}
fn sources(&self, lanes: &Lanes) -> Result<Vec<Box<dyn Source>>, ClawError> {
Ok(ForgeKind::ALL
.into_iter()
.map(|kind| {
Box::new(ForgeSource::new(
&lanes.forge,
kind.name(),
self.watches.clone(),
self.accounts.clone(),
self.notifications.clone(),
)) as Box<dyn Source>
})
.collect())
}
}
/// The accounts layer: the account tools that manage identities over
/// chat, and the one tool that acts with a stored credential — the
/// secret reaches a command's environment and never returns.
struct AccountsExtension {
accounts: Arc<dyn rhema_identity::IdentityStore>,
watches: Arc<crate::watch_store::ClawStore>,
workspace: PathBuf,
}
impl ClawExtension for AccountsExtension {
fn name(&self) -> &'static str {
"accounts"
}
fn tools(&self, _key: &ResourceKey) -> Vec<Arc<dyn ToolResolver>> {
vec![
permissive(crate::accounts_tools::registry(
self.accounts.clone(),
self.watches.clone(),
)),
Arc::new(crate::accounts_tools::IdentityExec::new(
self.accounts.clone(),
self.workspace.clone(),
)),
]
}
}
/// The presentation identity: the `identity_set` tool, so the model
/// writes its own name, mark, and images the surface serves.
struct IdentityExtension {
identity: Arc<crate::identity::IdentityStore>,
workspace: PathBuf,
}
impl ClawExtension for IdentityExtension {
fn name(&self) -> &'static str {
"identity"
}
fn tools(&self, _key: &ResourceKey) -> Vec<Arc<dyn ToolResolver>> {
vec![permissive(crate::identity::registry(
self.identity.clone(),
self.workspace.clone(),
))]
}
}
/// The consent bridge: the `ask_secret`/`request_permission` tools
/// that park a running turn on a human answer, and the routes that
/// deliver it. Key-scoped: an ask lands on the session that asked.
struct ConsentsExtension {
consents: Arc<crate::consents::ConsentStore>,
bridge: Arc<WaitBridge>,
hub: Arc<EventHub>,
}
impl ClawExtension for ConsentsExtension {
fn name(&self) -> &'static str {
"consents"
}
fn tools(&self, key: &ResourceKey) -> Vec<Arc<dyn ToolResolver>> {
vec![permissive(crate::consents::registry(
self.consents.clone(),
self.bridge.clone(),
self.hub.clone(),
key.clone(),
))]
}
}
/// The attachments: the `attach_file` tool that copies a workspace
/// file into a fetchable card. Key-scoped: a card lands on the session
/// that attached it.
struct AttachmentsExtension {
store: Arc<crate::attachments::AttachmentStore>,
workspace: PathBuf,
hub: Arc<EventHub>,
}
impl ClawExtension for AttachmentsExtension {
fn name(&self) -> &'static str {
"attachments"
}
fn tools(&self, key: &ResourceKey) -> Vec<Arc<dyn ToolResolver>> {
vec![permissive(crate::attachments::registry(
self.store.clone(),
self.workspace.clone(),
self.hub.clone(),
key.clone(),
))]
}
}
/// The phone: the `phone` tool's consent-gated control actions and the
/// status it reads. Key-scoped: a command queued from `phone:desk`
/// belongs to that device's session.
struct PhoneExtension {
store: Arc<crate::phone::PhoneStore>,
consents: Arc<crate::consents::ConsentStore>,
bridge: Arc<WaitBridge>,
hub: Arc<EventHub>,
}
impl ClawExtension for PhoneExtension {
fn name(&self) -> &'static str {
"phone"
}
fn tools(&self, key: &ResourceKey) -> Vec<Arc<dyn ToolResolver>> {
vec![permissive(crate::phone::registry(
self.store.clone(),
self.consents.clone(),
self.bridge.clone(),
self.hub.clone(),
key.clone(),
))]
}
}
/// The calendar reminders: a due occurrence is an event on the resident
/// lane, never a timer near the model.
struct CalendarExtension {
calendars: Vec<CalendarWatch>,
}
impl ClawExtension for CalendarExtension {
fn name(&self) -> &'static str {
"calendar"
}
fn sources(&self, lanes: &Lanes) -> Result<Vec<Box<dyn Source>>, ClawError> {
let source =
CalendarSource::new(&lanes.interactive, Arc::new(SystemClock), &self.calendars)
.map_err(ClawError::Backend)?;
Ok(vec![Box::new(source)])
}
}
+199 -186
View File
@@ -40,11 +40,12 @@
//! the rest report interrupted.
//!
//! Deterministic setup first (spec §1, §8): the allowlist — permissive,
//! v1's starting point — and the resolver chain (the shell toolset over
//! the interactive workspace, the memory tools, `bootstrap_save`, the
//! notify tool, the task tools, and the watch tools) are assembled
//! before the first model call; the model never decides its own
//! permissions. The system prompt is rendered per dispatch from the
//! v1's starting point — and the resolver chain are assembled before
//! the first model call; the model never decides its own permissions.
//! The chain is the core toolsets (the shell over the interactive
//! workspace, memory, `bootstrap_save`) plus every
//! [`crate::extension::ClawExtension`]'s tools in the standard set's
//! order. The system prompt is rendered per dispatch from the
//! bootstrap template over the live SOUL/USER files.
use std::collections::HashMap;
@@ -67,7 +68,6 @@ use crate::backends::BackendRegistry;
use crate::bootstrap::{Bootstrap, DEFAULT_SYSTEM_MD, render_system};
use crate::hub::{EventHub, TurnEvent};
use crate::notifications::NotificationStore;
use crate::watch_store::ClawStore;
/// The key prefixes of the resident context (see the module docs):
/// `interactive:`, `task:`, `phone:`, and `calendar:`. Forge keys —
@@ -158,16 +158,6 @@ pub struct InteractiveHandler {
backends: Arc<BackendRegistry>,
workspace: PathBuf,
tools: Arc<ShellTools>,
watch_resolver: Arc<dyn rhema_core::ToolResolver>,
/// The identity tools: interactive-only by construction — the forge
/// handler never resolves them, so a forge comment cannot reach the
/// accounts.
accounts_resolver: Arc<dyn rhema_core::ToolResolver>,
/// The one tool that acts with a stored credential: the secret is
/// injected into a command's environment and never returns.
identity_exec: Arc<dyn rhema_core::ToolResolver>,
notify_resolver: Arc<dyn rhema_core::ToolResolver>,
task_resolver: Arc<dyn rhema_core::ToolResolver>,
/// The durable cross-session store the memory tools act on; the
/// resolver is built per dispatch so its scope tracks the workspace.
memory: Arc<dyn MemoryStore>,
@@ -179,98 +169,52 @@ pub struct InteractiveHandler {
/// The notification table: the `notify` tool writes it, and
/// non-interactive replies auto-notify through it.
notifications: Arc<NotificationStore>,
/// The consent table the `ask_secret` tool asks through and the
/// consent routes answer into.
consents: Arc<crate::consents::ConsentStore>,
/// The wake bridge between those routes and the tools waiting on
/// their rows.
bridge: Arc<crate::consents::WaitBridge>,
/// The presentation-identity tool: the same store the settings
/// pages write, offered to the model as `identity_set`.
identity_resolver: Arc<dyn rhema_core::ToolResolver>,
/// The attachment store the `attach_file` tool copies into and the
/// fetch route serves from.
attachments: Arc<crate::attachments::AttachmentStore>,
/// The phone state and command queue the `phone` tool reads and
/// queues through.
phone: Arc<crate::phone::PhoneStore>,
/// The coding-agent tool: instances and async jobs the resident
/// drives a full rhema-coding-agent session through.
job_resolver: Arc<dyn rhema_core::ToolResolver>,
/// How many tool rounds one turn may run before it is cut off — the
/// config's runaway guard for an agent meant to work unattended; a
/// follow-up message keeps going.
max_turn_steps: u32,
/// The extension set the tool half of the chain is cut from, per
/// dispatch, in the standard set's order — the same set whose
/// sources the daemon's tick drives and whose boot pass runs first.
extensions: Arc<crate::extension::Extensions>,
}
impl InteractiveHandler {
/// A handler whose chat turns run in `workspace` (the interactive
/// scratch space under the daemon's workspace root), whose watch
/// tools mutate `watches`, whose notifications land in
/// `notifications`, whose task tools manage `tasks`, and whose
/// `coding_agent` tool enqueues jobs into `jobs`. Approval is
/// v1-permissive — the shell is the point of a resident coding
/// agent — installed once, since resolution is synchronous within a
/// turn.
#[allow(clippy::too_many_arguments)] // sixteen parts, each load-bearing
/// scratch space under the daemon's workspace root) and whose tool
/// half of the resolver chain comes from `extensions` — the same
/// set whose sources the daemon's tick drives and whose boot pass
/// runs at assembly. The core toolsets are the handler's own: the
/// shell (approval v1-permissive — the shell is the point of a
/// resident coding agent — installed once, since resolution is
/// synchronous within a turn), memory, and `bootstrap_save`.
#[allow(clippy::too_many_arguments)] // eight parts, each load-bearing
pub fn new(
backends: Arc<BackendRegistry>,
workspace: impl Into<PathBuf>,
watches: Arc<ClawStore>,
memory: Arc<dyn MemoryStore>,
bootstrap: Arc<Bootstrap>,
hub: Arc<EventHub>,
notifications: Arc<NotificationStore>,
tasks: Arc<crate::task_store::TaskStore>,
jobs: Arc<crate::job_store::CodingJobStore>,
accounts: Arc<dyn rhema_identity::IdentityStore>,
max_turn_steps: u32,
consents: Arc<crate::consents::ConsentStore>,
bridge: Arc<crate::consents::WaitBridge>,
attachments: Arc<crate::attachments::AttachmentStore>,
phone: Arc<crate::phone::PhoneStore>,
identity: Arc<crate::identity::IdentityStore>,
extensions: Arc<crate::extension::Extensions>,
) -> Self {
let workspace = workspace.into();
let tools = Arc::new(ShellTools::new(&workspace));
tools.install_approval(Arc::new(AllowAll));
let watch_resolver =
crate::watch_tools::registry(watches.clone()).resolver_for(&Allowlist::permissive());
let accounts_resolver = crate::accounts_tools::registry(accounts.clone(), watches)
.resolver_for(&Allowlist::permissive());
let identity_exec: Arc<dyn rhema_core::ToolResolver> = Arc::new(
crate::accounts_tools::IdentityExec::new(accounts, workspace.clone()),
);
let notify_resolver = crate::notify_tools::registry(notifications.clone())
.resolver_for(&Allowlist::permissive());
let task_resolver =
crate::task_tools::registry(tasks).resolver_for(&Allowlist::permissive());
let job_resolver = crate::job_tools::registry(jobs).resolver_for(&Allowlist::permissive());
let bootstrap_resolver =
crate::bootstrap::registry(bootstrap.clone()).resolver_for(&Allowlist::permissive());
let identity_resolver = crate::identity::registry(identity.clone(), workspace.clone())
.resolver_for(&Allowlist::permissive());
Self {
backends,
workspace,
tools,
job_resolver,
watch_resolver,
accounts_resolver,
identity_exec,
notify_resolver,
task_resolver,
memory,
bootstrap,
bootstrap_resolver,
identity_resolver,
hub,
notifications,
consents,
bridge,
attachments,
phone,
max_turn_steps,
extensions,
}
}
@@ -307,49 +251,24 @@ impl InteractiveHandler {
.map_err(|err| format!("cannot build the interactive agent: {err}"))
}
/// The resolver chain one dispatch runs under: the shell toolset
/// over the interactive workspace, then the resident toolsets. The
/// consent and attachment tools build per dispatch — their asks and
/// cards must land on the session that asked, and the handler
/// The resolver chain one dispatch runs under: the core toolsets —
/// the shell over the interactive workspace, memory,
/// `bootstrap_save` — then every extension's tools in the standard
/// set's order. The whole chain is cut per dispatch: the
/// key-scoped toolsets (consents, attachments, phone) must bind
/// their asks and cards to the session that asked, and the handler
/// serves every session.
fn resolver(&self, key: &ResourceKey) -> Arc<ResolverChain> {
Arc::new(ResolverChain::new(vec![
let mut resolvers: Vec<Arc<dyn rhema_core::ToolResolver>> = vec![
self.tools.clone(),
Arc::new(MemoryResolver::new(
self.memory.clone(),
Scope::Workspace(self.workspace.clone()),
)),
self.bootstrap_resolver.clone(),
self.notify_resolver.clone(),
self.task_resolver.clone(),
self.job_resolver.clone(),
self.watch_resolver.clone(),
self.accounts_resolver.clone(),
self.identity_exec.clone(),
self.identity_resolver.clone(),
crate::consents::registry(
self.consents.clone(),
self.bridge.clone(),
self.hub.clone(),
key.clone(),
)
.resolver_for(&Allowlist::permissive()),
crate::attachments::registry(
self.attachments.clone(),
self.workspace.clone(),
self.hub.clone(),
key.clone(),
)
.resolver_for(&Allowlist::permissive()),
crate::phone::registry(
self.phone.clone(),
self.consents.clone(),
self.bridge.clone(),
self.hub.clone(),
key.clone(),
)
.resolver_for(&Allowlist::permissive()),
]))
];
resolvers.extend(self.extensions.tools(key));
Arc::new(ResolverChain::new(resolvers))
}
/// One dispatch: drain, restore, turn, stash, reply.
@@ -634,6 +553,7 @@ mod tests {
use crate::notifications::NotificationStore;
use crate::task_store::TaskStore;
use crate::watch_store::ClawStore;
use crate::backends::{Backend, BackendRegistry};
use rhema_core::{
@@ -713,6 +633,43 @@ mod tests {
Arc::new(crate::attachments::AttachmentStore::open(root).expect("store opens"))
}
/// The standard extension set over the tests' throwaway stores —
/// the same chain the daemon assembles, built per test so each
/// assertion runs against its own tables.
#[allow(clippy::too_many_arguments)]
fn test_extensions(
hub: Arc<EventHub>,
watches: Arc<ClawStore>,
notifications: Arc<NotificationStore>,
tasks: Arc<TaskStore>,
jobs: Arc<crate::job_store::CodingJobStore>,
accounts: Arc<dyn rhema_identity::IdentityStore>,
consents: Arc<crate::consents::ConsentStore>,
bridge: Arc<crate::consents::WaitBridge>,
attachments: Arc<crate::attachments::AttachmentStore>,
phone: Arc<crate::phone::PhoneStore>,
identity: Arc<crate::identity::IdentityStore>,
workspace: &Path,
) -> Arc<crate::extension::Extensions> {
Arc::new(crate::extension::Extensions::standard(
crate::extension::ExtensionDeps {
watches,
accounts,
notifications,
tasks,
jobs,
consents,
bridge,
attachments,
phone,
identity,
hub,
workspace: workspace.to_path_buf(),
calendars: Vec::new(),
},
))
}
fn test_registry(provider: Arc<dyn rhema_core::ModelProvider>) -> Arc<BackendRegistry> {
Arc::new(
BackendRegistry::new(vec![Backend::headless("mock", provider)], None)
@@ -778,23 +735,30 @@ mod tests {
let store = Arc::new(ClawStore::in_memory().expect("store opens"));
let hub = Arc::new(EventHub::default());
let workspace = tempfile::tempdir().expect("tempdir");
let handler = InteractiveHandler::new(
test_registry(recorder.clone()),
workspace.path().join("interactive"),
store,
Arc::new(rhema_memory::VolatileStore::new()),
Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")),
let notifications = notifications();
let extensions = test_extensions(
hub.clone(),
notifications(),
store,
notifications.clone(),
tasks(),
jobs(),
accounts(),
crate::config::DEFAULT_MAX_TURN_STEPS,
consents(),
bridge(),
attachments(),
phone(),
identity(),
&workspace.path().join("interactive"),
);
let handler = InteractiveHandler::new(
test_registry(recorder.clone()),
workspace.path().join("interactive"),
Arc::new(rhema_memory::VolatileStore::new()),
Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")),
hub.clone(),
notifications.clone(),
crate::config::DEFAULT_MAX_TURN_STEPS,
extensions,
);
let dispatcher = Arc::new(Dispatcher::new(
DispatcherConfig {
@@ -857,23 +821,31 @@ mod tests {
// must name every tool the chain advertises, with its schema —
// what the prompt lists is what the request carries.
let workspace = tempfile::tempdir().expect("tempdir");
let handler = InteractiveHandler::new(
test_registry(Arc::new(MockProvider::new([reply("hi back")]))),
workspace.path().join("interactive"),
let hub = Arc::new(EventHub::default());
let notifications = notifications();
let extensions = test_extensions(
hub.clone(),
Arc::new(ClawStore::in_memory().expect("store opens")),
Arc::new(rhema_memory::VolatileStore::new()),
Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")),
Arc::new(EventHub::default()),
notifications(),
notifications.clone(),
tasks(),
jobs(),
accounts(),
crate::config::DEFAULT_MAX_TURN_STEPS,
consents(),
bridge(),
attachments(),
phone(),
identity(),
&workspace.path().join("interactive"),
);
let handler = InteractiveHandler::new(
test_registry(Arc::new(MockProvider::new([reply("hi back")]))),
workspace.path().join("interactive"),
Arc::new(rhema_memory::VolatileStore::new()),
Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")),
hub.clone(),
notifications.clone(),
crate::config::DEFAULT_MAX_TURN_STEPS,
extensions,
);
let specs = handler.resolver(&Key::from("interactive:web")).specs();
assert!(
@@ -1120,23 +1092,30 @@ mod tests {
let store = Arc::new(ClawStore::in_memory().expect("store opens"));
let hub = Arc::new(EventHub::default());
let workspace = tempfile::tempdir().expect("tempdir");
let handler = InteractiveHandler::new(
test_registry(Arc::new(MockProvider::new([reply("hi back")]))),
workspace.path().join("interactive"),
store,
Arc::new(rhema_memory::VolatileStore::new()),
Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")),
let notifications = notifications();
let extensions = test_extensions(
hub.clone(),
notifications(),
store,
notifications.clone(),
tasks(),
jobs(),
accounts(),
crate::config::DEFAULT_MAX_TURN_STEPS,
consents(),
bridge(),
attachments(),
phone(),
identity(),
&workspace.path().join("interactive"),
);
let handler = InteractiveHandler::new(
test_registry(Arc::new(MockProvider::new([reply("hi back")]))),
workspace.path().join("interactive"),
Arc::new(rhema_memory::VolatileStore::new()),
Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")),
hub.clone(),
notifications.clone(),
crate::config::DEFAULT_MAX_TURN_STEPS,
extensions,
);
let dispatcher = Arc::new(Dispatcher::new(
@@ -1199,6 +1178,21 @@ mod tests {
let hub = Arc::new(EventHub::default());
let workspace = tempfile::tempdir().expect("tempdir");
let workspace_path = workspace.path().join("interactive");
let notifications = notifications();
let extensions = test_extensions(
hub.clone(),
store,
notifications.clone(),
tasks(),
jobs(),
accounts(),
consents(),
bridge(),
attachments(),
phone(),
identity(),
&workspace_path,
);
let handler = InteractiveHandler::new(
test_registry(Arc::new(MockProvider::new([
scripted_call(
@@ -1211,20 +1205,12 @@ mod tests {
reply("the release channel is #deploys."),
]))),
workspace_path.clone(),
store,
memory.clone(),
Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")),
hub,
notifications(),
tasks(),
jobs(),
accounts(),
hub.clone(),
notifications.clone(),
crate::config::DEFAULT_MAX_TURN_STEPS,
consents(),
bridge(),
attachments(),
phone(),
identity(),
extensions,
);
let dispatcher = Arc::new(Dispatcher::new(
@@ -1292,6 +1278,21 @@ mod tests {
let store = Arc::new(ClawStore::in_memory().expect("store opens"));
let hub = Arc::new(EventHub::default());
let workspace = tempfile::tempdir().expect("tempdir");
let notifications = notifications();
let extensions = test_extensions(
hub.clone(),
store,
notifications.clone(),
tasks(),
jobs(),
accounts(),
consents(),
bridge(),
attachments(),
phone(),
identity(),
&workspace.path().join("interactive"),
);
let handler = InteractiveHandler::new(
test_registry(Arc::new(MockProvider::new([
scripted_call(
@@ -1308,20 +1309,12 @@ mod tests {
reply("setup is done."),
]))),
workspace.path().join("interactive"),
store,
Arc::new(rhema_memory::VolatileStore::new()),
bootstrap.clone(),
hub,
notifications(),
tasks(),
jobs(),
accounts(),
hub.clone(),
notifications.clone(),
crate::config::DEFAULT_MAX_TURN_STEPS,
consents(),
bridge(),
attachments(),
phone(),
identity(),
extensions,
);
let dispatcher = Arc::new(Dispatcher::new(
@@ -1378,23 +1371,30 @@ mod tests {
let store = Arc::new(ClawStore::in_memory().expect("store opens"));
let hub = Arc::new(EventHub::default());
let workspace = tempfile::tempdir().expect("tempdir");
let handler = InteractiveHandler::new(
test_registry(Arc::new(MockProvider::new([reply("hi back")]))),
workspace.path().join("interactive"),
let notifications = notifications();
let extensions = test_extensions(
hub.clone(),
store,
Arc::new(rhema_memory::VolatileStore::new()),
Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")),
hub,
notifications(),
notifications.clone(),
tasks(),
jobs(),
accounts(),
crate::config::DEFAULT_MAX_TURN_STEPS,
consents(),
bridge(),
attachments(),
phone(),
identity(),
&workspace.path().join("interactive"),
);
let handler = InteractiveHandler::new(
test_registry(Arc::new(MockProvider::new([reply("hi back")]))),
workspace.path().join("interactive"),
Arc::new(rhema_memory::VolatileStore::new()),
Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")),
hub.clone(),
notifications.clone(),
crate::config::DEFAULT_MAX_TURN_STEPS,
extensions,
);
let backing = Arc::new(VolatileStore::new());
let dispatcher = Arc::new(Dispatcher::new(
@@ -1450,26 +1450,32 @@ mod tests {
let hub = Arc::new(EventHub::default());
let workspace = tempfile::tempdir().expect("tempdir");
let backing = Arc::new(VolatileStore::new());
let extensions = test_extensions(
hub.clone(),
store,
notifications.clone(),
tasks(),
jobs(),
accounts(),
consents(),
bridge(),
attachments(),
phone(),
identity(),
&workspace.path().join("interactive"),
);
let handler = Arc::new(InteractiveHandler::new(
test_registry(Arc::new(MockProvider::new([
reply("the bread is done."),
reply("hi back"),
]))),
workspace.path().join("interactive"),
store,
Arc::new(rhema_memory::VolatileStore::new()),
Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")),
hub,
hub.clone(),
notifications.clone(),
tasks(),
jobs(),
accounts(),
crate::config::DEFAULT_MAX_TURN_STEPS,
consents(),
bridge(),
attachments(),
phone(),
identity(),
extensions,
));
let dispatcher = Arc::new(Dispatcher::new(
@@ -1566,26 +1572,33 @@ mod tests {
let store = Arc::new(ClawStore::in_memory().expect("store opens"));
let hub = Arc::new(EventHub::default());
let workspace = tempfile::tempdir().expect("tempdir");
let notifications = notifications();
let extensions = test_extensions(
hub.clone(),
store,
notifications.clone(),
tasks(),
jobs(),
accounts(),
consents(),
bridge(),
attachments(),
phone(),
identity(),
&workspace.path().join("interactive"),
);
let handler = InteractiveHandler::new(
test_registry(Arc::new(MockProvider::new([
reply("noted, leaving early."),
reply("evening summary: shipped the claw."),
]))),
workspace.path().join("interactive"),
store,
Arc::new(rhema_memory::VolatileStore::new()),
Arc::new(Bootstrap::new(workspace.path().join("bootstrap")).expect("store opens")),
hub,
notifications(),
tasks(),
jobs(),
accounts(),
hub.clone(),
notifications.clone(),
crate::config::DEFAULT_MAX_TURN_STEPS,
consents(),
bridge(),
attachments(),
phone(),
identity(),
extensions,
);
let backing = Arc::new(VolatileStore::new());
let dispatcher = Arc::new(Dispatcher::new(
+5
View File
@@ -45,6 +45,9 @@
//! - [`watch_tools`] — the agent's watch-management tool set
//! - [`notify_tools`] — the `notify` tool: the queue a device polls
//! - [`task_tools`] — `schedule_task`, `list_tasks`, `cancel_task`
//! - [`extension`] — the extension seam: the `ClawExtension` trait and
//! the standard set — the tool chain, the detect passes, and the
//! boot pass all iterate it
//! - [`forge`] — the sources: one detectable stream per forge kind
//! - [`calendar`] — the [`CalendarSource`]: due calendar reminders in,
//! events out
@@ -71,6 +74,7 @@ pub mod config;
pub mod consents;
pub mod daemon;
pub mod environment;
pub mod extension;
pub mod forge;
pub mod handler;
pub mod http;
@@ -104,6 +108,7 @@ pub use calendar::CalendarSource;
pub use config::{ClawConfig, ConfigError, ForgeKind, Watch};
pub use daemon::{ClawError, Daemon, TickReport, run_once};
pub use environment::write_report;
pub use extension::{ClawExtension, ExtensionDeps, Extensions, Lanes};
pub use forge::ForgeSource;
pub use handler::ClawHandler;
pub use hub::{EventHub, TurnEvent};