feat(agentcompany-rust): scaffold native shell and runtime bridge
This commit is contained in:
1 parent
34814b4dfd
commit
2cddf89717
40 files changed
+11300
-3
No files matched your search
@@ -50,9 +50,6 @@ Thumbs.db
|
|||||||
# Local reference material
|
# Local reference material
|
||||||
oldreference/
|
oldreference/
|
||||||
|
|
||||||
# Future rewrite — not part of the TypeScript app tree.
|
|
||||||
AgentCompany-Rust/
|
|
||||||
|
|
||||||
# Markdown docs
|
# Markdown docs
|
||||||
*.md
|
*.md
|
||||||
!/README.md
|
!/README.md
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
.DS_Store
|
||||||
Generated
+7427
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,42 @@
|
|||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
"crates/support",
|
||||||
|
"crates/domain",
|
||||||
|
"crates/storage",
|
||||||
|
"crates/pi_bridge",
|
||||||
|
"crates/runtime",
|
||||||
|
"crates/app_models",
|
||||||
|
"crates/app_ui",
|
||||||
|
"crates/app",
|
||||||
|
]
|
||||||
|
default-members = ["crates/app"]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
edition = "2024"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
publish = false
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
anyhow = "1.0.100"
|
||||||
|
dirs = "6.0.0"
|
||||||
|
gpui = "0.2.2"
|
||||||
|
parking_lot = "0.12.5"
|
||||||
|
rusqlite = { version = "0.37.0", features = ["bundled", "serde_json"] }
|
||||||
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
serde_json = "1.0.145"
|
||||||
|
smallvec = "1.15.1"
|
||||||
|
tempfile = "3.23.0"
|
||||||
|
thiserror = "2.0.17"
|
||||||
|
time = { version = "0.3.44", features = ["formatting", "macros"] }
|
||||||
|
uuid = { version = "1.18.1", features = ["serde", "v4"] }
|
||||||
|
unicode-segmentation = "1.12.0"
|
||||||
|
|
||||||
|
app_models = { path = "crates/app_models" }
|
||||||
|
app_ui = { path = "crates/app_ui" }
|
||||||
|
domain = { path = "crates/domain" }
|
||||||
|
pi_bridge = { path = "crates/pi_bridge" }
|
||||||
|
runtime = { path = "crates/runtime" }
|
||||||
|
storage = { path = "crates/storage" }
|
||||||
|
support = { path = "crates/support" }
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "agentcompany_app"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "agentcompany"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
app_ui.workspace = true
|
||||||
|
parking_lot.workspace = true
|
||||||
|
storage.workspace = true
|
||||||
|
support.workspace = true
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use storage::ProfileStore;
|
||||||
|
use support::paths::resolve_profile_dir;
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let profile_dir = resolve_profile_dir()?;
|
||||||
|
let store = Arc::new(Mutex::new(ProfileStore::open(&profile_dir)?));
|
||||||
|
let snapshot = store.lock().snapshot()?;
|
||||||
|
app_ui::launch(store, snapshot);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[package]
|
||||||
|
name = "app_models"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain.workspace = true
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
use domain::SectionId;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum AppAction {
|
||||||
|
SelectSection(SectionId),
|
||||||
|
SelectRun(Option<String>),
|
||||||
|
SelectApproval(Option<String>),
|
||||||
|
SelectMessage(Option<String>),
|
||||||
|
SelectDocument(Option<String>),
|
||||||
|
SelectWorkspace(Option<String>),
|
||||||
|
Status(String),
|
||||||
|
ClearStatus,
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod actions;
|
||||||
|
pub mod state;
|
||||||
|
|
||||||
|
pub use actions::AppAction;
|
||||||
|
pub use state::AppState;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
use domain::SectionId;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub section: SectionId,
|
||||||
|
pub selected_run_id: Option<String>,
|
||||||
|
pub selected_approval_id: Option<String>,
|
||||||
|
pub selected_message_id: Option<String>,
|
||||||
|
pub selected_document_id: Option<String>,
|
||||||
|
pub selected_workspace_id: Option<String>,
|
||||||
|
pub status_line: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn set_status(&mut self, value: impl Into<String>) {
|
||||||
|
self.status_line = Some(value.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_status(&mut self) {
|
||||||
|
self.status_line = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "app_ui"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
app_models.workspace = true
|
||||||
|
domain.workspace = true
|
||||||
|
gpui.workspace = true
|
||||||
|
parking_lot.workspace = true
|
||||||
|
storage.workspace = true
|
||||||
|
unicode-segmentation.workspace = true
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,37 @@
|
|||||||
|
mod app_root;
|
||||||
|
mod widgets;
|
||||||
|
|
||||||
|
use app_root::AppRoot;
|
||||||
|
use domain::ProfileSnapshot;
|
||||||
|
use gpui::{App, AppContext, Application, Bounds, WindowBounds, WindowOptions, px, size};
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use storage::ProfileStore;
|
||||||
|
use widgets::text_input::bind_text_input_keys;
|
||||||
|
|
||||||
|
pub type SharedStore = Arc<Mutex<ProfileStore>>;
|
||||||
|
|
||||||
|
pub fn launch(store: SharedStore, snapshot: ProfileSnapshot) {
|
||||||
|
Application::new().run(move |cx: &mut App| {
|
||||||
|
bind_text_input_keys(cx);
|
||||||
|
let store = store.clone();
|
||||||
|
let snapshot = snapshot.clone();
|
||||||
|
let bounds = Bounds::centered(None, size(px(1380.0), px(920.0)), cx);
|
||||||
|
let Ok(window) = cx.open_window(
|
||||||
|
WindowOptions {
|
||||||
|
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
move |_, cx| cx.new(|cx| AppRoot::new(store.clone(), snapshot.clone(), cx)),
|
||||||
|
) else {
|
||||||
|
cx.quit();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
window
|
||||||
|
.update(cx, |_, window, cx| {
|
||||||
|
cx.activate(true);
|
||||||
|
window.activate_window();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
use gpui::{Context, SharedString, Window, div, prelude::*, rgb};
|
||||||
|
|
||||||
|
pub fn primary_button<V>(
|
||||||
|
label: &'static str,
|
||||||
|
cx: &mut Context<V>,
|
||||||
|
on_click: impl Fn(&mut V, &mut Context<V>) + 'static,
|
||||||
|
) -> impl IntoElement
|
||||||
|
where
|
||||||
|
V: 'static,
|
||||||
|
{
|
||||||
|
div()
|
||||||
|
.id(SharedString::from(format!("primary-button-{label}")))
|
||||||
|
.px_3()
|
||||||
|
.py_2()
|
||||||
|
.rounded_md()
|
||||||
|
.cursor_pointer()
|
||||||
|
.bg(rgb(0x9e3d2d))
|
||||||
|
.text_color(rgb(0xfff7f0))
|
||||||
|
.hover(|style| style.bg(rgb(0xb24f3e)))
|
||||||
|
.on_click(
|
||||||
|
cx.listener(move |this, _: &gpui::ClickEvent, _: &mut Window, cx| on_click(this, cx)),
|
||||||
|
)
|
||||||
|
.child(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn secondary_button<V>(
|
||||||
|
label: &'static str,
|
||||||
|
cx: &mut Context<V>,
|
||||||
|
on_click: impl Fn(&mut V, &mut Context<V>) + 'static,
|
||||||
|
) -> impl IntoElement
|
||||||
|
where
|
||||||
|
V: 'static,
|
||||||
|
{
|
||||||
|
div()
|
||||||
|
.id(SharedString::from(format!("secondary-button-{label}")))
|
||||||
|
.px_3()
|
||||||
|
.py_2()
|
||||||
|
.rounded_md()
|
||||||
|
.cursor_pointer()
|
||||||
|
.bg(rgb(0xfffbf5))
|
||||||
|
.border_1()
|
||||||
|
.border_color(rgb(0xd9cdbf))
|
||||||
|
.text_color(rgb(0x171411))
|
||||||
|
.hover(|style| style.bg(rgb(0xf4ede4)))
|
||||||
|
.on_click(
|
||||||
|
cx.listener(move |this, _: &gpui::ClickEvent, _: &mut Window, cx| on_click(this, cx)),
|
||||||
|
)
|
||||||
|
.child(label)
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod button;
|
||||||
|
pub mod text_input;
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
use gpui::{
|
||||||
|
App, Bounds, Context, CursorStyle, Element, ElementId, ElementInputHandler, Entity,
|
||||||
|
EntityInputHandler, FocusHandle, Focusable, GlobalElementId, KeyBinding, LayoutId, MouseButton,
|
||||||
|
MouseDownEvent, PaintQuad, Pixels, Point, ShapedLine, SharedString, Style, TextRun,
|
||||||
|
UTF16Selection, Window, actions, div, fill, hsla, point, prelude::*, px, relative, rgb, size,
|
||||||
|
white,
|
||||||
|
};
|
||||||
|
use std::ops::Range;
|
||||||
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
|
|
||||||
|
actions!(
|
||||||
|
text_input,
|
||||||
|
[
|
||||||
|
Backspace,
|
||||||
|
Delete,
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
Home,
|
||||||
|
End,
|
||||||
|
Paste,
|
||||||
|
ShowCharacterPalette
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
pub fn bind_text_input_keys(cx: &mut App) {
|
||||||
|
cx.bind_keys([
|
||||||
|
KeyBinding::new("backspace", Backspace, None),
|
||||||
|
KeyBinding::new("delete", Delete, None),
|
||||||
|
KeyBinding::new("left", Left, None),
|
||||||
|
KeyBinding::new("right", Right, None),
|
||||||
|
KeyBinding::new("home", Home, None),
|
||||||
|
KeyBinding::new("end", End, None),
|
||||||
|
KeyBinding::new("cmd-v", Paste, None),
|
||||||
|
KeyBinding::new("ctrl-cmd-space", ShowCharacterPalette, None),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TextInput {
|
||||||
|
focus_handle: FocusHandle,
|
||||||
|
content: SharedString,
|
||||||
|
placeholder: SharedString,
|
||||||
|
cursor: usize,
|
||||||
|
last_layout: Option<ShapedLine>,
|
||||||
|
last_bounds: Option<Bounds<Pixels>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextInput {
|
||||||
|
pub fn new(cx: &mut App, placeholder: impl Into<SharedString>) -> Entity<Self> {
|
||||||
|
let placeholder = placeholder.into();
|
||||||
|
cx.new(|cx| Self {
|
||||||
|
focus_handle: cx.focus_handle(),
|
||||||
|
content: "".into(),
|
||||||
|
placeholder,
|
||||||
|
cursor: 0,
|
||||||
|
last_layout: None,
|
||||||
|
last_bounds: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn value(&self) -> String {
|
||||||
|
self.content.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.content = "".into();
|
||||||
|
self.cursor = 0;
|
||||||
|
self.last_layout = None;
|
||||||
|
self.last_bounds = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_to(&mut self, offset: usize, cx: &mut Context<Self>) {
|
||||||
|
self.cursor = offset.min(self.content.len());
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn previous_boundary(&self) -> usize {
|
||||||
|
self.content
|
||||||
|
.grapheme_indices(true)
|
||||||
|
.rev()
|
||||||
|
.find_map(|(index, _)| (index < self.cursor).then_some(index))
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_boundary(&self) -> usize {
|
||||||
|
self.content
|
||||||
|
.grapheme_indices(true)
|
||||||
|
.find_map(|(index, _)| (index > self.cursor).then_some(index))
|
||||||
|
.unwrap_or(self.content.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn offset_from_utf16(&self, offset: usize) -> usize {
|
||||||
|
let mut utf8_offset = 0;
|
||||||
|
let mut utf16_count = 0;
|
||||||
|
for ch in self.content.chars() {
|
||||||
|
if utf16_count >= offset {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
utf16_count += ch.len_utf16();
|
||||||
|
utf8_offset += ch.len_utf8();
|
||||||
|
}
|
||||||
|
utf8_offset
|
||||||
|
}
|
||||||
|
|
||||||
|
fn offset_to_utf16(&self, offset: usize) -> usize {
|
||||||
|
let mut utf16_offset = 0;
|
||||||
|
let mut utf8_count = 0;
|
||||||
|
for ch in self.content.chars() {
|
||||||
|
if utf8_count >= offset {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
utf8_count += ch.len_utf8();
|
||||||
|
utf16_offset += ch.len_utf16();
|
||||||
|
}
|
||||||
|
utf16_offset
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_for_mouse_position(&self, position: Point<Pixels>) -> usize {
|
||||||
|
let (Some(bounds), Some(line)) = (self.last_bounds.as_ref(), self.last_layout.as_ref())
|
||||||
|
else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
if position.x <= bounds.left() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if position.x >= bounds.right() {
|
||||||
|
return self.content.len();
|
||||||
|
}
|
||||||
|
line.closest_index_for_x(position.x - bounds.left())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.move_to(self.previous_boundary(), cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.move_to(self.next_boundary(), cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.move_to(0, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.move_to(self.content.len(), cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let previous = self.previous_boundary();
|
||||||
|
if previous < self.cursor {
|
||||||
|
self.replace_text_in_range(
|
||||||
|
Some(self.offset_to_utf16(previous)..self.offset_to_utf16(self.cursor)),
|
||||||
|
"",
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let next = self.next_boundary();
|
||||||
|
if self.cursor < next {
|
||||||
|
self.replace_text_in_range(
|
||||||
|
Some(self.offset_to_utf16(self.cursor)..self.offset_to_utf16(next)),
|
||||||
|
"",
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
|
||||||
|
self.replace_text_in_range(None, &text.replace('\n', " "), window, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_mouse_down(
|
||||||
|
&mut self,
|
||||||
|
event: &MouseDownEvent,
|
||||||
|
_window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
self.move_to(self.index_for_mouse_position(event.position), cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_character_palette(
|
||||||
|
&mut self,
|
||||||
|
_: &ShowCharacterPalette,
|
||||||
|
window: &mut Window,
|
||||||
|
_: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
window.show_character_palette();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EntityInputHandler for TextInput {
|
||||||
|
fn text_for_range(
|
||||||
|
&mut self,
|
||||||
|
range_utf16: Range<usize>,
|
||||||
|
actual_range: &mut Option<Range<usize>>,
|
||||||
|
_window: &mut Window,
|
||||||
|
_cx: &mut Context<Self>,
|
||||||
|
) -> Option<String> {
|
||||||
|
let range =
|
||||||
|
self.offset_from_utf16(range_utf16.start)..self.offset_from_utf16(range_utf16.end);
|
||||||
|
actual_range.replace(self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end));
|
||||||
|
Some(self.content[range].to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_text_range(
|
||||||
|
&mut self,
|
||||||
|
_ignore_disabled_input: bool,
|
||||||
|
_window: &mut Window,
|
||||||
|
_cx: &mut Context<Self>,
|
||||||
|
) -> Option<UTF16Selection> {
|
||||||
|
let cursor = self.offset_to_utf16(self.cursor);
|
||||||
|
Some(UTF16Selection {
|
||||||
|
range: cursor..cursor,
|
||||||
|
reversed: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn marked_text_range(
|
||||||
|
&self,
|
||||||
|
_window: &mut Window,
|
||||||
|
_cx: &mut Context<Self>,
|
||||||
|
) -> Option<Range<usize>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
|
||||||
|
|
||||||
|
fn replace_text_in_range(
|
||||||
|
&mut self,
|
||||||
|
range_utf16: Option<Range<usize>>,
|
||||||
|
new_text: &str,
|
||||||
|
_window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let range = range_utf16
|
||||||
|
.map(|range| self.offset_from_utf16(range.start)..self.offset_from_utf16(range.end))
|
||||||
|
.unwrap_or(self.cursor..self.cursor);
|
||||||
|
self.content = format!(
|
||||||
|
"{}{}{}",
|
||||||
|
&self.content[..range.start],
|
||||||
|
new_text,
|
||||||
|
&self.content[range.end..]
|
||||||
|
)
|
||||||
|
.into();
|
||||||
|
self.cursor = range.start + new_text.len();
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_and_mark_text_in_range(
|
||||||
|
&mut self,
|
||||||
|
range_utf16: Option<Range<usize>>,
|
||||||
|
new_text: &str,
|
||||||
|
_new_selected_range_utf16: Option<Range<usize>>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
self.replace_text_in_range(range_utf16, new_text, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bounds_for_range(
|
||||||
|
&mut self,
|
||||||
|
range_utf16: Range<usize>,
|
||||||
|
bounds: Bounds<Pixels>,
|
||||||
|
_window: &mut Window,
|
||||||
|
_cx: &mut Context<Self>,
|
||||||
|
) -> Option<Bounds<Pixels>> {
|
||||||
|
let layout = self.last_layout.as_ref()?;
|
||||||
|
let start = self.offset_from_utf16(range_utf16.start);
|
||||||
|
let end = self.offset_from_utf16(range_utf16.end);
|
||||||
|
Some(Bounds::from_corners(
|
||||||
|
point(bounds.left() + layout.x_for_index(start), bounds.top()),
|
||||||
|
point(bounds.left() + layout.x_for_index(end), bounds.bottom()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn character_index_for_point(
|
||||||
|
&mut self,
|
||||||
|
point: Point<Pixels>,
|
||||||
|
_window: &mut Window,
|
||||||
|
_cx: &mut Context<Self>,
|
||||||
|
) -> Option<usize> {
|
||||||
|
let layout = self.last_layout.as_ref()?;
|
||||||
|
let bounds = self.last_bounds.as_ref()?;
|
||||||
|
let index = layout.index_for_x(point.x - bounds.left())?;
|
||||||
|
Some(self.offset_to_utf16(index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TextElement {
|
||||||
|
input: Entity<TextInput>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PrepaintState {
|
||||||
|
line: ShapedLine,
|
||||||
|
cursor: PaintQuad,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoElement for TextElement {
|
||||||
|
type Element = Self;
|
||||||
|
|
||||||
|
fn into_element(self) -> Self::Element {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for TextElement {
|
||||||
|
type RequestLayoutState = ();
|
||||||
|
type PrepaintState = PrepaintState;
|
||||||
|
|
||||||
|
fn id(&self) -> Option<ElementId> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_layout(
|
||||||
|
&mut self,
|
||||||
|
_id: Option<&GlobalElementId>,
|
||||||
|
_inspector_id: Option<&gpui::InspectorElementId>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> (LayoutId, Self::RequestLayoutState) {
|
||||||
|
let mut style = Style::default();
|
||||||
|
style.size.width = relative(1.0).into();
|
||||||
|
style.size.height = window.line_height().into();
|
||||||
|
(window.request_layout(style, [], cx), ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepaint(
|
||||||
|
&mut self,
|
||||||
|
_id: Option<&GlobalElementId>,
|
||||||
|
_inspector_id: Option<&gpui::InspectorElementId>,
|
||||||
|
bounds: Bounds<Pixels>,
|
||||||
|
_request_layout: &mut Self::RequestLayoutState,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> Self::PrepaintState {
|
||||||
|
let input = self.input.read(cx);
|
||||||
|
let style = window.text_style();
|
||||||
|
let text = if input.content.is_empty() {
|
||||||
|
input.placeholder.clone()
|
||||||
|
} else {
|
||||||
|
input.content.clone()
|
||||||
|
};
|
||||||
|
let color = if input.content.is_empty() {
|
||||||
|
hsla(0.0, 0.0, 0.0, 0.35)
|
||||||
|
} else {
|
||||||
|
style.color
|
||||||
|
};
|
||||||
|
let run = TextRun {
|
||||||
|
len: text.len(),
|
||||||
|
font: style.font(),
|
||||||
|
color,
|
||||||
|
background_color: None,
|
||||||
|
underline: None,
|
||||||
|
strikethrough: None,
|
||||||
|
};
|
||||||
|
let font_size = style.font_size.to_pixels(window.rem_size());
|
||||||
|
let line = window
|
||||||
|
.text_system()
|
||||||
|
.shape_line(text, font_size, &[run], None);
|
||||||
|
let cursor_x = bounds.left() + line.x_for_index(input.cursor);
|
||||||
|
let cursor = fill(
|
||||||
|
Bounds::new(
|
||||||
|
point(cursor_x, bounds.top()),
|
||||||
|
size(px(2.0), bounds.bottom() - bounds.top()),
|
||||||
|
),
|
||||||
|
rgb(0x9e3d2d),
|
||||||
|
);
|
||||||
|
PrepaintState { line, cursor }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(
|
||||||
|
&mut self,
|
||||||
|
_id: Option<&GlobalElementId>,
|
||||||
|
_inspector_id: Option<&gpui::InspectorElementId>,
|
||||||
|
bounds: Bounds<Pixels>,
|
||||||
|
_request_layout: &mut Self::RequestLayoutState,
|
||||||
|
prepaint: &mut Self::PrepaintState,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
let focus_handle = self.input.read(cx).focus_handle.clone();
|
||||||
|
window.handle_input(
|
||||||
|
&focus_handle,
|
||||||
|
ElementInputHandler::new(bounds, self.input.clone()),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
prepaint
|
||||||
|
.line
|
||||||
|
.paint(bounds.origin, window.line_height(), window, cx)
|
||||||
|
.ok();
|
||||||
|
if focus_handle.is_focused(window) {
|
||||||
|
window.paint_quad(prepaint.cursor.clone());
|
||||||
|
}
|
||||||
|
let line = prepaint.line.clone();
|
||||||
|
self.input.update(cx, |input, _| {
|
||||||
|
input.last_layout = Some(line);
|
||||||
|
input.last_bounds = Some(bounds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for TextInput {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div()
|
||||||
|
.key_context("TextInput")
|
||||||
|
.track_focus(&self.focus_handle(cx))
|
||||||
|
.cursor(CursorStyle::IBeam)
|
||||||
|
.on_action(cx.listener(Self::backspace))
|
||||||
|
.on_action(cx.listener(Self::delete))
|
||||||
|
.on_action(cx.listener(Self::left))
|
||||||
|
.on_action(cx.listener(Self::right))
|
||||||
|
.on_action(cx.listener(Self::home))
|
||||||
|
.on_action(cx.listener(Self::end))
|
||||||
|
.on_action(cx.listener(Self::paste))
|
||||||
|
.on_action(cx.listener(Self::show_character_palette))
|
||||||
|
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
|
||||||
|
.bg(rgb(0xfffbf5))
|
||||||
|
.border_1()
|
||||||
|
.border_color(rgb(0xd9cdbf))
|
||||||
|
.rounded_md()
|
||||||
|
.px_3()
|
||||||
|
.py_2()
|
||||||
|
.line_height(px(24.0))
|
||||||
|
.text_size(px(18.0))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.h(px(24.0))
|
||||||
|
.w_full()
|
||||||
|
.bg(white())
|
||||||
|
.child(TextElement { input: cx.entity() }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Focusable for TextInput {
|
||||||
|
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||||
|
self.focus_handle.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "domain"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct ConnectorSeed {
|
||||||
|
pub id: &'static str,
|
||||||
|
pub label: &'static str,
|
||||||
|
pub description: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const CONNECTOR_SEEDS: &[ConnectorSeed] = &[
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "claude-pro",
|
||||||
|
label: "Claude Pro",
|
||||||
|
description: "Anthropic OAuth session for Claude Pro.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "openai-codex",
|
||||||
|
label: "OpenAI Codex",
|
||||||
|
description: "OpenAI OAuth session for Codex desktop flows.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "github-copilot",
|
||||||
|
label: "GitHub Copilot",
|
||||||
|
description: "GitHub Copilot OAuth session.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "gemini-cli",
|
||||||
|
label: "Gemini CLI",
|
||||||
|
description: "Google Gemini CLI OAuth session.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "antigravity",
|
||||||
|
label: "Antigravity",
|
||||||
|
description: "Google Antigravity OAuth session.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "anthropic",
|
||||||
|
label: "Anthropic",
|
||||||
|
description: "Anthropic API key connector.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "openai",
|
||||||
|
label: "OpenAI",
|
||||||
|
description: "OpenAI API key connector.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "google",
|
||||||
|
label: "Google",
|
||||||
|
description: "Google AI Studio API key connector.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "mistral",
|
||||||
|
label: "Mistral",
|
||||||
|
description: "Mistral API key connector.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "groq",
|
||||||
|
label: "Groq",
|
||||||
|
description: "Groq API key connector.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "cerebras",
|
||||||
|
label: "Cerebras",
|
||||||
|
description: "Cerebras API key connector.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "xai",
|
||||||
|
label: "xAI",
|
||||||
|
description: "xAI API key connector.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "openrouter",
|
||||||
|
label: "OpenRouter",
|
||||||
|
description: "OpenRouter API key connector.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "vercel-ai-gateway",
|
||||||
|
label: "Vercel AI Gateway",
|
||||||
|
description: "Vercel AI Gateway API key connector.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "zai",
|
||||||
|
label: "Z AI",
|
||||||
|
description: "Z AI API key connector.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "opencode",
|
||||||
|
label: "OpenCode",
|
||||||
|
description: "OpenCode API key connector.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "opencode-go",
|
||||||
|
label: "OpenCode Go",
|
||||||
|
description: "OpenCode Go API key connector.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "huggingface",
|
||||||
|
label: "Hugging Face",
|
||||||
|
description: "Hugging Face API key connector.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "kimi-coding",
|
||||||
|
label: "Kimi Coding",
|
||||||
|
description: "Kimi Coding API key connector.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "minimax",
|
||||||
|
label: "MiniMax",
|
||||||
|
description: "MiniMax global API connector.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "minimax-cn",
|
||||||
|
label: "MiniMax CN",
|
||||||
|
description: "MiniMax China API connector.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "fireworks",
|
||||||
|
label: "Fireworks",
|
||||||
|
description: "Fireworks API key connector.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "fireworks-firepass",
|
||||||
|
label: "Fireworks Firepass",
|
||||||
|
description: "Fireworks Firepass API key connector.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "amazon-bedrock",
|
||||||
|
label: "Amazon Bedrock",
|
||||||
|
description: "AWS Bedrock cloud connector.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "azure-openai",
|
||||||
|
label: "Azure OpenAI",
|
||||||
|
description: "Azure OpenAI cloud connector.",
|
||||||
|
},
|
||||||
|
ConnectorSeed {
|
||||||
|
id: "google-vertex",
|
||||||
|
label: "Google Vertex",
|
||||||
|
description: "Google Vertex AI cloud connector.",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SectionId {
|
||||||
|
#[default]
|
||||||
|
Overview,
|
||||||
|
Runs,
|
||||||
|
Inbox,
|
||||||
|
Messages,
|
||||||
|
Approvals,
|
||||||
|
Documents,
|
||||||
|
Workspaces,
|
||||||
|
Settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SectionId {
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Overview => "Dashboard",
|
||||||
|
Self::Runs => "Runs",
|
||||||
|
Self::Inbox => "Inbox",
|
||||||
|
Self::Messages => "Messages",
|
||||||
|
Self::Approvals => "Approvals",
|
||||||
|
Self::Documents => "Documents",
|
||||||
|
Self::Workspaces => "Workspaces",
|
||||||
|
Self::Settings => "Settings",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CompanyStatus {
|
||||||
|
#[default]
|
||||||
|
Active,
|
||||||
|
Paused,
|
||||||
|
Archived,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ExecutionMode {
|
||||||
|
#[default]
|
||||||
|
AiCeo,
|
||||||
|
AiCofounder,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum WorkspaceState {
|
||||||
|
Ready,
|
||||||
|
Busy,
|
||||||
|
Blocked,
|
||||||
|
Missing,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum AgentStatus {
|
||||||
|
Idle,
|
||||||
|
Active,
|
||||||
|
Running,
|
||||||
|
Paused,
|
||||||
|
Error,
|
||||||
|
PendingApproval,
|
||||||
|
Terminated,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TaskStatus {
|
||||||
|
Backlog,
|
||||||
|
Todo,
|
||||||
|
InProgress,
|
||||||
|
InReview,
|
||||||
|
Done,
|
||||||
|
Blocked,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ApprovalState {
|
||||||
|
Pending,
|
||||||
|
Approved,
|
||||||
|
Rejected,
|
||||||
|
RevisionRequested,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApprovalState {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Pending => "pending",
|
||||||
|
Self::Approved => "approved",
|
||||||
|
Self::Rejected => "rejected",
|
||||||
|
Self::RevisionRequested => "revision_requested",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MessageChannel {
|
||||||
|
Direct,
|
||||||
|
Company,
|
||||||
|
Department,
|
||||||
|
Project,
|
||||||
|
Incident,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DocumentStatus {
|
||||||
|
Draft,
|
||||||
|
InReview,
|
||||||
|
Approved,
|
||||||
|
Archived,
|
||||||
|
Superseded,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ThreadWaitingFor {
|
||||||
|
HumanApproval,
|
||||||
|
HumanReply,
|
||||||
|
ToolResult,
|
||||||
|
ExternalEvent,
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
pub mod catalog;
|
||||||
|
pub mod enums;
|
||||||
|
pub mod models;
|
||||||
|
pub mod snapshot;
|
||||||
|
|
||||||
|
pub use catalog::{CONNECTOR_SEEDS, ConnectorSeed};
|
||||||
|
pub use enums::*;
|
||||||
|
pub use models::*;
|
||||||
|
pub use snapshot::ProfileSnapshot;
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Company {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub status: String,
|
||||||
|
pub auto_approve_hires: bool,
|
||||||
|
pub review_deliverables: bool,
|
||||||
|
pub execution_mode: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Connector {
|
||||||
|
pub id: String,
|
||||||
|
pub label: String,
|
||||||
|
pub description: String,
|
||||||
|
pub status: String,
|
||||||
|
pub version: Option<String>,
|
||||||
|
pub auth_state: String,
|
||||||
|
pub last_checked_at: Option<String>,
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub env_binding_text: String,
|
||||||
|
pub notes: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Workspace {
|
||||||
|
pub id: String,
|
||||||
|
pub company_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub local_path: String,
|
||||||
|
pub repo_url: String,
|
||||||
|
pub repo_ref: String,
|
||||||
|
pub project_id: Option<String>,
|
||||||
|
pub state: String,
|
||||||
|
pub is_primary: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Agent {
|
||||||
|
pub id: String,
|
||||||
|
pub company_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub role: String,
|
||||||
|
pub title: String,
|
||||||
|
pub department: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub reports_to: Option<String>,
|
||||||
|
pub connector_id: String,
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub capabilities: String,
|
||||||
|
pub budget_monthly_usd: f64,
|
||||||
|
pub spent_monthly_usd: f64,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Goal {
|
||||||
|
pub id: String,
|
||||||
|
pub company_id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub parent_id: Option<String>,
|
||||||
|
pub owner_agent_id: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Project {
|
||||||
|
pub id: String,
|
||||||
|
pub company_id: String,
|
||||||
|
pub goal_id: Option<String>,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub lead_agent_id: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub target_date: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Task {
|
||||||
|
pub id: String,
|
||||||
|
pub company_id: String,
|
||||||
|
pub project_id: Option<String>,
|
||||||
|
pub goal_id: Option<String>,
|
||||||
|
pub parent_id: Option<String>,
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub assignee_agent_id: Option<String>,
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub priority: String,
|
||||||
|
pub active_run_id: Option<String>,
|
||||||
|
pub cost_usd: f64,
|
||||||
|
pub task_type: Option<String>,
|
||||||
|
pub requires_user_review: bool,
|
||||||
|
pub started_at: Option<String>,
|
||||||
|
pub completed_at: Option<String>,
|
||||||
|
pub cancelled_at: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Approval {
|
||||||
|
pub id: String,
|
||||||
|
pub company_id: String,
|
||||||
|
pub related_task_id: Option<String>,
|
||||||
|
pub related_agent_id: Option<String>,
|
||||||
|
pub requested_by_agent_id: Option<String>,
|
||||||
|
pub approval_type: String,
|
||||||
|
pub payload_summary: String,
|
||||||
|
pub impact_summary: String,
|
||||||
|
pub payload_json: Option<String>,
|
||||||
|
pub decision_note: String,
|
||||||
|
pub state: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Run {
|
||||||
|
pub id: String,
|
||||||
|
pub company_id: String,
|
||||||
|
pub task_id: Option<String>,
|
||||||
|
pub agent_id: String,
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
pub connector_id: String,
|
||||||
|
pub status: String,
|
||||||
|
pub summary: String,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub cost_usd: Option<f64>,
|
||||||
|
pub started_at: Option<String>,
|
||||||
|
pub finished_at: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Activity {
|
||||||
|
pub id: String,
|
||||||
|
pub company_id: String,
|
||||||
|
pub actor: String,
|
||||||
|
pub action: String,
|
||||||
|
pub entity_type: String,
|
||||||
|
pub entity_id: String,
|
||||||
|
pub detail: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct AgentMessage {
|
||||||
|
pub id: String,
|
||||||
|
pub company_id: String,
|
||||||
|
pub from_agent_id: String,
|
||||||
|
pub to_agent_id: Option<String>,
|
||||||
|
pub sender_kind: String,
|
||||||
|
pub channel: String,
|
||||||
|
pub channel_target_id: Option<String>,
|
||||||
|
pub subject: String,
|
||||||
|
pub body: String,
|
||||||
|
pub priority: String,
|
||||||
|
pub read_at: Option<String>,
|
||||||
|
pub parent_message_id: Option<String>,
|
||||||
|
pub attachments_json: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Document {
|
||||||
|
pub id: String,
|
||||||
|
pub company_id: String,
|
||||||
|
pub document_type: String,
|
||||||
|
pub title: String,
|
||||||
|
pub content: String,
|
||||||
|
pub author_agent_id: Option<String>,
|
||||||
|
pub reviewer_agent_id: Option<String>,
|
||||||
|
pub project_id: Option<String>,
|
||||||
|
pub goal_id: Option<String>,
|
||||||
|
pub parent_doc_id: Option<String>,
|
||||||
|
pub version: i64,
|
||||||
|
pub status: String,
|
||||||
|
pub tags_json: String,
|
||||||
|
pub latest_revision_id: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct ThreadWaitState {
|
||||||
|
pub id: String,
|
||||||
|
pub thread_id: String,
|
||||||
|
pub company_id: String,
|
||||||
|
pub waiting_for: String,
|
||||||
|
pub related_approval_id: Option<String>,
|
||||||
|
pub reason: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub resolved_at: Option<String>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
use crate::{
|
||||||
|
Activity, Agent, AgentMessage, Approval, Company, Connector, Document, Goal, Project, Run,
|
||||||
|
Task, Workspace,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileSnapshot {
|
||||||
|
pub current_company_id: Option<String>,
|
||||||
|
pub profile_path: String,
|
||||||
|
pub theme: String,
|
||||||
|
pub locale: String,
|
||||||
|
pub companies: Vec<Company>,
|
||||||
|
pub connectors: Vec<Connector>,
|
||||||
|
pub workspaces: Vec<Workspace>,
|
||||||
|
pub agents: Vec<Agent>,
|
||||||
|
pub goals: Vec<Goal>,
|
||||||
|
pub projects: Vec<Project>,
|
||||||
|
pub tasks: Vec<Task>,
|
||||||
|
pub approvals: Vec<Approval>,
|
||||||
|
pub runs: Vec<Run>,
|
||||||
|
pub activity: Vec<Activity>,
|
||||||
|
pub agent_messages: Vec<AgentMessage>,
|
||||||
|
pub documents: Vec<Document>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProfileSnapshot {
|
||||||
|
pub fn current_company(&self) -> Option<&Company> {
|
||||||
|
self.current_company_id
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|company_id| {
|
||||||
|
self.companies
|
||||||
|
.iter()
|
||||||
|
.find(|company| &company.id == company_id)
|
||||||
|
})
|
||||||
|
.or_else(|| self.companies.first())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unread_messages(&self) -> usize {
|
||||||
|
self.agent_messages
|
||||||
|
.iter()
|
||||||
|
.filter(|message| message.read_at.is_none())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pending_approvals(&self) -> usize {
|
||||||
|
self.approvals
|
||||||
|
.iter()
|
||||||
|
.filter(|approval| approval.state == "pending")
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active_tasks(&self) -> usize {
|
||||||
|
self.tasks
|
||||||
|
.iter()
|
||||||
|
.filter(|task| matches!(task.status.as_str(), "todo" | "in_progress" | "in_review"))
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active_runs(&self) -> usize {
|
||||||
|
self.runs
|
||||||
|
.iter()
|
||||||
|
.filter(|run| {
|
||||||
|
matches!(
|
||||||
|
run.status.as_str(),
|
||||||
|
"queued" | "running" | "waiting_approval" | "paused"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "pi_bridge"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
use crate::{ClientRequest, RuntimeEvent};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::{
|
||||||
|
io::{BufRead, BufReader, Write},
|
||||||
|
path::PathBuf,
|
||||||
|
process::{Child, ChildStdin, ChildStdout, Command, Stdio},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct PiBridgeClient {
|
||||||
|
child: Child,
|
||||||
|
stdin: ChildStdin,
|
||||||
|
stdout: BufReader<ChildStdout>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PiBridgeClient {
|
||||||
|
pub fn spawn(command: PathBuf, args: &[String], working_dir: Option<PathBuf>) -> Result<Self> {
|
||||||
|
let mut process = Command::new(&command);
|
||||||
|
process
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::inherit());
|
||||||
|
|
||||||
|
if let Some(dir) = working_dir {
|
||||||
|
process.current_dir(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut child = process
|
||||||
|
.spawn()
|
||||||
|
.with_context(|| format!("failed to spawn Pi Mono sidecar at {}", command.display()))?;
|
||||||
|
let stdin = child
|
||||||
|
.stdin
|
||||||
|
.take()
|
||||||
|
.context("Pi Mono sidecar stdin is unavailable")?;
|
||||||
|
let stdout = child
|
||||||
|
.stdout
|
||||||
|
.take()
|
||||||
|
.context("Pi Mono sidecar stdout is unavailable")?;
|
||||||
|
Ok(Self {
|
||||||
|
child,
|
||||||
|
stdin,
|
||||||
|
stdout: BufReader::new(stdout),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send(&mut self, message: &ClientRequest) -> Result<()> {
|
||||||
|
let payload =
|
||||||
|
serde_json::to_vec(message).context("failed to serialize Pi bridge request")?;
|
||||||
|
self.stdin
|
||||||
|
.write_all(&payload)
|
||||||
|
.context("failed to write Pi bridge request")?;
|
||||||
|
self.stdin
|
||||||
|
.write_all(b"\n")
|
||||||
|
.context("failed to terminate Pi bridge request")?;
|
||||||
|
self.stdin
|
||||||
|
.flush()
|
||||||
|
.context("failed to flush Pi bridge request")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_event(&mut self) -> Result<RuntimeEvent> {
|
||||||
|
let mut line = String::new();
|
||||||
|
let bytes = self
|
||||||
|
.stdout
|
||||||
|
.read_line(&mut line)
|
||||||
|
.context("failed to read Pi bridge event")?;
|
||||||
|
if bytes == 0 {
|
||||||
|
return Err(anyhow::anyhow!("Pi Mono sidecar closed stdout"));
|
||||||
|
}
|
||||||
|
serde_json::from_str(line.trim()).context("failed to decode Pi bridge event")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wait(&mut self) -> Result<()> {
|
||||||
|
self.child
|
||||||
|
.wait()
|
||||||
|
.context("failed while waiting for Pi Mono sidecar")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod client;
|
||||||
|
pub mod protocol;
|
||||||
|
|
||||||
|
pub use client::PiBridgeClient;
|
||||||
|
pub use protocol::*;
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct RuntimeHandshake {
|
||||||
|
pub protocol_version: u32,
|
||||||
|
pub app_name: String,
|
||||||
|
pub app_version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct RunBudget {
|
||||||
|
pub thinking_level: Option<String>,
|
||||||
|
pub token_budget: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct RunRequest {
|
||||||
|
pub run_id: String,
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
pub company_id: String,
|
||||||
|
pub task_id: Option<String>,
|
||||||
|
pub agent_id: String,
|
||||||
|
pub workspace_path: Option<String>,
|
||||||
|
pub connector_id: String,
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub prompt: String,
|
||||||
|
pub context_json: serde_json::Value,
|
||||||
|
pub budget: RunBudget,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ToolApprovalDecision {
|
||||||
|
pub run_id: String,
|
||||||
|
pub tool_call_id: String,
|
||||||
|
pub approved: bool,
|
||||||
|
pub note: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SteerRequest {
|
||||||
|
pub run_id: String,
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct FollowUpRequest {
|
||||||
|
pub run_id: String,
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum ClientRequest {
|
||||||
|
Handshake(RuntimeHandshake),
|
||||||
|
StartRun(Box<RunRequest>),
|
||||||
|
ToolApproval(ToolApprovalDecision),
|
||||||
|
Steer(SteerRequest),
|
||||||
|
FollowUp(FollowUpRequest),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum RuntimeEvent {
|
||||||
|
HandshakeAck {
|
||||||
|
protocol_version: u32,
|
||||||
|
runtime_version: String,
|
||||||
|
},
|
||||||
|
Message {
|
||||||
|
run_id: String,
|
||||||
|
role: String,
|
||||||
|
body: String,
|
||||||
|
},
|
||||||
|
ToolCall {
|
||||||
|
run_id: String,
|
||||||
|
tool_call_id: String,
|
||||||
|
tool_name: String,
|
||||||
|
input_json: serde_json::Value,
|
||||||
|
},
|
||||||
|
ToolResult {
|
||||||
|
run_id: String,
|
||||||
|
tool_call_id: String,
|
||||||
|
output_json: serde_json::Value,
|
||||||
|
},
|
||||||
|
Artifact {
|
||||||
|
run_id: String,
|
||||||
|
provider: String,
|
||||||
|
external_id: String,
|
||||||
|
title: String,
|
||||||
|
metadata_json: serde_json::Value,
|
||||||
|
},
|
||||||
|
RunFinished {
|
||||||
|
run_id: String,
|
||||||
|
status: String,
|
||||||
|
summary: String,
|
||||||
|
session_id: Option<String>,
|
||||||
|
cost_usd: Option<f64>,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "runtime"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
pi_bridge.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod service;
|
||||||
|
|
||||||
|
pub use service::{RuntimeConfig, RuntimeService};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use pi_bridge::{
|
||||||
|
ClientRequest, FollowUpRequest, PiBridgeClient, RunRequest, RuntimeEvent, RuntimeHandshake,
|
||||||
|
SteerRequest, ToolApprovalDecision,
|
||||||
|
};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct RuntimeConfig {
|
||||||
|
pub command: PathBuf,
|
||||||
|
pub args: Vec<String>,
|
||||||
|
pub working_dir: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RuntimeService {
|
||||||
|
client: PiBridgeClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeService {
|
||||||
|
pub fn connect(config: RuntimeConfig) -> Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
client: PiBridgeClient::spawn(config.command, &config.args, config.working_dir)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handshake(&mut self, message: RuntimeHandshake) -> Result<RuntimeEvent> {
|
||||||
|
self.client.send(&ClientRequest::Handshake(message))?;
|
||||||
|
self.client.read_event()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_run(&mut self, request: RunRequest) -> Result<()> {
|
||||||
|
self.client
|
||||||
|
.send(&ClientRequest::StartRun(Box::new(request)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decide_tool_call(&mut self, decision: ToolApprovalDecision) -> Result<()> {
|
||||||
|
self.client.send(&ClientRequest::ToolApproval(decision))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn steer(&mut self, request: SteerRequest) -> Result<()> {
|
||||||
|
self.client.send(&ClientRequest::Steer(request))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn follow_up(&mut self, request: FollowUpRequest) -> Result<()> {
|
||||||
|
self.client.send(&ClientRequest::FollowUp(request))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_event(&mut self) -> Result<RuntimeEvent> {
|
||||||
|
self.client.read_event()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
use pi_bridge::{
|
||||||
|
FollowUpRequest, RunBudget, RunRequest, RuntimeEvent, RuntimeHandshake, SteerRequest,
|
||||||
|
ToolApprovalDecision,
|
||||||
|
};
|
||||||
|
use runtime::{RuntimeConfig, RuntimeService};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn fake_sidecar_script() -> &'static str {
|
||||||
|
r#"
|
||||||
|
while IFS= read -r line; do
|
||||||
|
case "$line" in
|
||||||
|
*'"kind":"handshake"'*)
|
||||||
|
printf '%s\n' '{"kind":"handshake_ack","protocol_version":1,"runtime_version":"pi-mono-test"}'
|
||||||
|
;;
|
||||||
|
*'"kind":"start_run"'*)
|
||||||
|
printf '%s\n' '{"kind":"message","run_id":"run-1","role":"assistant","body":"Run queued"}'
|
||||||
|
printf '%s\n' '{"kind":"tool_call","run_id":"run-1","tool_call_id":"tool-1","tool_name":"write_file","input_json":{"path":"README.md"}}'
|
||||||
|
;;
|
||||||
|
*'"kind":"tool_approval"'*)
|
||||||
|
printf '%s\n' '{"kind":"tool_result","run_id":"run-1","tool_call_id":"tool-1","output_json":{"status":"approved"}}'
|
||||||
|
printf '%s\n' '{"kind":"run_finished","run_id":"run-1","status":"completed","summary":"Run complete","session_id":"session-1","cost_usd":1.25}'
|
||||||
|
;;
|
||||||
|
*'"kind":"steer"'*)
|
||||||
|
printf '%s\n' '{"kind":"message","run_id":"run-1","role":"system","body":"Steer accepted"}'
|
||||||
|
;;
|
||||||
|
*'"kind":"follow_up"'*)
|
||||||
|
printf '%s\n' '{"kind":"message","run_id":"run-1","role":"system","body":"Follow-up accepted"}'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
"#
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_service_round_trips_sidecar_contract() {
|
||||||
|
let mut service = RuntimeService::connect(RuntimeConfig {
|
||||||
|
command: PathBuf::from("/bin/sh"),
|
||||||
|
args: vec!["-c".to_string(), fake_sidecar_script().to_string()],
|
||||||
|
working_dir: None,
|
||||||
|
})
|
||||||
|
.expect("connect runtime");
|
||||||
|
|
||||||
|
let handshake = service
|
||||||
|
.handshake(RuntimeHandshake {
|
||||||
|
protocol_version: 1,
|
||||||
|
app_name: "AgentCompany-Rust".to_string(),
|
||||||
|
app_version: "0.1.0".to_string(),
|
||||||
|
})
|
||||||
|
.expect("handshake");
|
||||||
|
match handshake {
|
||||||
|
RuntimeEvent::HandshakeAck {
|
||||||
|
protocol_version,
|
||||||
|
runtime_version,
|
||||||
|
} => {
|
||||||
|
assert_eq!(protocol_version, 1);
|
||||||
|
assert_eq!(runtime_version, "pi-mono-test");
|
||||||
|
}
|
||||||
|
other => panic!("unexpected handshake event: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
service
|
||||||
|
.start_run(RunRequest {
|
||||||
|
run_id: "run-1".to_string(),
|
||||||
|
session_id: Some("session-1".to_string()),
|
||||||
|
company_id: "company-1".to_string(),
|
||||||
|
task_id: Some("task-1".to_string()),
|
||||||
|
agent_id: "agent-1".to_string(),
|
||||||
|
workspace_path: Some("/tmp/agentcompany".to_string()),
|
||||||
|
connector_id: "anthropic".to_string(),
|
||||||
|
model: Some("claude-sonnet".to_string()),
|
||||||
|
prompt: "Draft the weekly status update".to_string(),
|
||||||
|
context_json: serde_json::json!({ "thread_id": "thread-1" }),
|
||||||
|
budget: RunBudget {
|
||||||
|
thinking_level: Some("high".to_string()),
|
||||||
|
token_budget: Some(4096),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect("start run");
|
||||||
|
|
||||||
|
let first_event = service.next_event().expect("first event");
|
||||||
|
match first_event {
|
||||||
|
RuntimeEvent::Message { run_id, role, body } => {
|
||||||
|
assert_eq!(run_id, "run-1");
|
||||||
|
assert_eq!(role, "assistant");
|
||||||
|
assert_eq!(body, "Run queued");
|
||||||
|
}
|
||||||
|
other => panic!("unexpected first event: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let second_event = service.next_event().expect("second event");
|
||||||
|
match second_event {
|
||||||
|
RuntimeEvent::ToolCall {
|
||||||
|
run_id,
|
||||||
|
tool_call_id,
|
||||||
|
tool_name,
|
||||||
|
input_json,
|
||||||
|
} => {
|
||||||
|
assert_eq!(run_id, "run-1");
|
||||||
|
assert_eq!(tool_call_id, "tool-1");
|
||||||
|
assert_eq!(tool_name, "write_file");
|
||||||
|
assert_eq!(input_json["path"], "README.md");
|
||||||
|
}
|
||||||
|
other => panic!("unexpected second event: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
service
|
||||||
|
.decide_tool_call(ToolApprovalDecision {
|
||||||
|
run_id: "run-1".to_string(),
|
||||||
|
tool_call_id: "tool-1".to_string(),
|
||||||
|
approved: true,
|
||||||
|
note: "Approved from test.".to_string(),
|
||||||
|
})
|
||||||
|
.expect("tool approval");
|
||||||
|
|
||||||
|
let third_event = service.next_event().expect("third event");
|
||||||
|
match third_event {
|
||||||
|
RuntimeEvent::ToolResult {
|
||||||
|
run_id,
|
||||||
|
tool_call_id,
|
||||||
|
output_json,
|
||||||
|
} => {
|
||||||
|
assert_eq!(run_id, "run-1");
|
||||||
|
assert_eq!(tool_call_id, "tool-1");
|
||||||
|
assert_eq!(output_json["status"], "approved");
|
||||||
|
}
|
||||||
|
other => panic!("unexpected third event: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let fourth_event = service.next_event().expect("fourth event");
|
||||||
|
match fourth_event {
|
||||||
|
RuntimeEvent::RunFinished {
|
||||||
|
run_id,
|
||||||
|
status,
|
||||||
|
summary,
|
||||||
|
session_id,
|
||||||
|
cost_usd,
|
||||||
|
} => {
|
||||||
|
assert_eq!(run_id, "run-1");
|
||||||
|
assert_eq!(status, "completed");
|
||||||
|
assert_eq!(summary, "Run complete");
|
||||||
|
assert_eq!(session_id.as_deref(), Some("session-1"));
|
||||||
|
assert_eq!(cost_usd, Some(1.25));
|
||||||
|
}
|
||||||
|
other => panic!("unexpected fourth event: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
service
|
||||||
|
.steer(SteerRequest {
|
||||||
|
run_id: "run-1".to_string(),
|
||||||
|
body: "Tighten the summary.".to_string(),
|
||||||
|
})
|
||||||
|
.expect("steer");
|
||||||
|
let steer_event = service.next_event().expect("steer event");
|
||||||
|
match steer_event {
|
||||||
|
RuntimeEvent::Message { body, .. } => assert_eq!(body, "Steer accepted"),
|
||||||
|
other => panic!("unexpected steer event: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
service
|
||||||
|
.follow_up(FollowUpRequest {
|
||||||
|
run_id: "run-1".to_string(),
|
||||||
|
body: "Add customer risk notes.".to_string(),
|
||||||
|
})
|
||||||
|
.expect("follow up");
|
||||||
|
let follow_up_event = service.next_event().expect("follow up event");
|
||||||
|
match follow_up_event {
|
||||||
|
RuntimeEvent::Message { body, .. } => assert_eq!(body, "Follow-up accepted"),
|
||||||
|
other => panic!("unexpected follow-up event: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "storage"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
domain.workspace = true
|
||||||
|
rusqlite.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
support.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
mod schema;
|
||||||
|
mod store;
|
||||||
|
|
||||||
|
pub use store::{NewCompanyInput, ProfileStore};
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use domain::CONNECTOR_SEEDS;
|
||||||
|
use rusqlite::{Connection, params};
|
||||||
|
use support::time::now_iso;
|
||||||
|
|
||||||
|
pub fn initialize(conn: &Connection) -> Result<()> {
|
||||||
|
conn.execute_batch(
|
||||||
|
r#"
|
||||||
|
create table if not exists settings (
|
||||||
|
key text primary key,
|
||||||
|
value text not null
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists companies (
|
||||||
|
id text primary key,
|
||||||
|
name text not null,
|
||||||
|
description text not null default '',
|
||||||
|
status text not null default 'active',
|
||||||
|
auto_approve_hires integer not null default 0,
|
||||||
|
review_deliverables integer not null default 0,
|
||||||
|
execution_mode text not null default 'ai_ceo',
|
||||||
|
created_at text not null,
|
||||||
|
updated_at text not null
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists connectors (
|
||||||
|
id text primary key,
|
||||||
|
label text not null,
|
||||||
|
description text not null,
|
||||||
|
status text not null default 'not_installed',
|
||||||
|
command text not null default '',
|
||||||
|
version text null,
|
||||||
|
auth_state text not null default 'unknown',
|
||||||
|
last_checked_at text null,
|
||||||
|
last_error text null,
|
||||||
|
model text null,
|
||||||
|
env_binding_text text not null default '',
|
||||||
|
notes text not null default '',
|
||||||
|
updated_at text not null
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists workspaces (
|
||||||
|
id text primary key,
|
||||||
|
company_id text not null,
|
||||||
|
name text not null,
|
||||||
|
local_path text not null,
|
||||||
|
repo_url text not null default '',
|
||||||
|
repo_ref text not null default '',
|
||||||
|
project_id text null,
|
||||||
|
state text not null default 'ready',
|
||||||
|
is_primary integer not null default 0,
|
||||||
|
lock_holder_run_id text null,
|
||||||
|
lock_acquired_at text null,
|
||||||
|
created_at text not null,
|
||||||
|
updated_at text not null,
|
||||||
|
foreign key(company_id) references companies(id) on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists agents (
|
||||||
|
id text primary key,
|
||||||
|
company_id text not null,
|
||||||
|
name text not null,
|
||||||
|
role text not null default '',
|
||||||
|
title text not null default '',
|
||||||
|
department text null,
|
||||||
|
status text not null default 'idle',
|
||||||
|
reports_to text null,
|
||||||
|
connector_id text not null default 'anthropic',
|
||||||
|
workspace_id text null,
|
||||||
|
model text null,
|
||||||
|
capabilities text not null default '[]',
|
||||||
|
budget_monthly_usd real not null default 0,
|
||||||
|
spent_monthly_usd real not null default 0,
|
||||||
|
metadata_json text not null default '{}',
|
||||||
|
heartbeat_enabled integer not null default 0,
|
||||||
|
heartbeat_interval_sec integer not null default 0,
|
||||||
|
last_heartbeat_at text null,
|
||||||
|
created_at text not null,
|
||||||
|
updated_at text not null,
|
||||||
|
foreign key(company_id) references companies(id) on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists goals (
|
||||||
|
id text primary key,
|
||||||
|
company_id text not null,
|
||||||
|
title text not null,
|
||||||
|
description text not null default '',
|
||||||
|
parent_id text null,
|
||||||
|
owner_agent_id text null,
|
||||||
|
status text not null default 'planned',
|
||||||
|
created_at text not null,
|
||||||
|
updated_at text not null,
|
||||||
|
foreign key(company_id) references companies(id) on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists projects (
|
||||||
|
id text primary key,
|
||||||
|
company_id text not null,
|
||||||
|
goal_id text null,
|
||||||
|
name text not null,
|
||||||
|
description text not null default '',
|
||||||
|
lead_agent_id text null,
|
||||||
|
status text not null default 'planned',
|
||||||
|
target_date text null,
|
||||||
|
created_at text not null,
|
||||||
|
updated_at text not null,
|
||||||
|
foreign key(company_id) references companies(id) on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists tasks (
|
||||||
|
id text primary key,
|
||||||
|
company_id text not null,
|
||||||
|
project_id text null,
|
||||||
|
goal_id text null,
|
||||||
|
parent_id text null,
|
||||||
|
title text not null,
|
||||||
|
description text not null default '',
|
||||||
|
assignee_agent_id text null,
|
||||||
|
workspace_id text null,
|
||||||
|
status text not null default 'todo',
|
||||||
|
priority text not null default 'medium',
|
||||||
|
active_run_id text null,
|
||||||
|
cost_usd real not null default 0,
|
||||||
|
task_type text null,
|
||||||
|
requires_user_review integer not null default 0,
|
||||||
|
started_at text null,
|
||||||
|
completed_at text null,
|
||||||
|
cancelled_at text null,
|
||||||
|
created_at text not null,
|
||||||
|
updated_at text not null,
|
||||||
|
foreign key(company_id) references companies(id) on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists approvals (
|
||||||
|
id text primary key,
|
||||||
|
company_id text not null,
|
||||||
|
related_task_id text null,
|
||||||
|
requested_by_agent_id text null,
|
||||||
|
related_agent_id text null,
|
||||||
|
type text not null,
|
||||||
|
payload_summary text not null default '',
|
||||||
|
impact_summary text not null default '',
|
||||||
|
payload_json text null,
|
||||||
|
decision_note text not null default '',
|
||||||
|
state text not null default 'pending',
|
||||||
|
created_at text not null,
|
||||||
|
updated_at text not null,
|
||||||
|
foreign key(company_id) references companies(id) on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists runs (
|
||||||
|
id text primary key,
|
||||||
|
company_id text not null,
|
||||||
|
task_id text null,
|
||||||
|
agent_id text not null,
|
||||||
|
workspace_id text null,
|
||||||
|
connector_id text not null,
|
||||||
|
status text not null default 'queued',
|
||||||
|
summary text not null default '',
|
||||||
|
error_message text null,
|
||||||
|
exit_code integer null,
|
||||||
|
signal text null,
|
||||||
|
model text null,
|
||||||
|
cost_usd real null,
|
||||||
|
unattributed_cost integer not null default 0,
|
||||||
|
started_at text null,
|
||||||
|
finished_at text null,
|
||||||
|
log_path text null,
|
||||||
|
created_at text not null,
|
||||||
|
updated_at text not null,
|
||||||
|
foreign key(company_id) references companies(id) on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists activity (
|
||||||
|
id text primary key,
|
||||||
|
company_id text not null,
|
||||||
|
actor text not null,
|
||||||
|
action text not null,
|
||||||
|
entity_type text not null,
|
||||||
|
entity_id text not null,
|
||||||
|
detail text not null,
|
||||||
|
created_at text not null,
|
||||||
|
foreign key(company_id) references companies(id) on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists agent_messages (
|
||||||
|
id text primary key,
|
||||||
|
company_id text not null,
|
||||||
|
from_agent_id text not null,
|
||||||
|
to_agent_id text null,
|
||||||
|
sender_kind text not null default 'human',
|
||||||
|
channel text not null default 'company',
|
||||||
|
channel_target_id text null,
|
||||||
|
subject text not null,
|
||||||
|
body text not null,
|
||||||
|
priority text not null default 'normal',
|
||||||
|
read_at text null,
|
||||||
|
parent_message_id text null,
|
||||||
|
attachments_json text not null default '[]',
|
||||||
|
created_at text not null,
|
||||||
|
foreign key(company_id) references companies(id) on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists agent_message_reads (
|
||||||
|
message_id text not null,
|
||||||
|
agent_id text not null,
|
||||||
|
read_at text not null,
|
||||||
|
primary key(message_id, agent_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists documents (
|
||||||
|
id text primary key,
|
||||||
|
company_id text not null,
|
||||||
|
type text not null,
|
||||||
|
title text not null,
|
||||||
|
content text not null default '',
|
||||||
|
author_agent_id text null,
|
||||||
|
reviewer_agent_id text null,
|
||||||
|
project_id text null,
|
||||||
|
goal_id text null,
|
||||||
|
parent_doc_id text null,
|
||||||
|
version integer not null default 1,
|
||||||
|
status text not null default 'draft',
|
||||||
|
tags_json text not null default '[]',
|
||||||
|
latest_revision_id text null,
|
||||||
|
source_run_id text null,
|
||||||
|
created_at text not null,
|
||||||
|
updated_at text not null,
|
||||||
|
foreign key(company_id) references companies(id) on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists document_revisions (
|
||||||
|
id text primary key,
|
||||||
|
company_id text not null,
|
||||||
|
document_id text not null,
|
||||||
|
revision_number integer not null,
|
||||||
|
body text not null,
|
||||||
|
change_summary text null,
|
||||||
|
created_by_agent_id text null,
|
||||||
|
created_by_user_id text null,
|
||||||
|
created_at text not null,
|
||||||
|
foreign key(company_id) references companies(id) on delete cascade,
|
||||||
|
foreign key(document_id) references documents(id) on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists conversation_threads (
|
||||||
|
id text primary key,
|
||||||
|
company_id text not null,
|
||||||
|
title text not null,
|
||||||
|
type text not null,
|
||||||
|
related_entity_type text null,
|
||||||
|
related_entity_id text null,
|
||||||
|
created_at text not null,
|
||||||
|
updated_at text not null,
|
||||||
|
foreign key(company_id) references companies(id) on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists conversation_events (
|
||||||
|
id text primary key,
|
||||||
|
thread_id text not null,
|
||||||
|
actor_id text not null,
|
||||||
|
actor_type text not null,
|
||||||
|
event_type text not null,
|
||||||
|
body text not null,
|
||||||
|
metadata_json text not null default '{}',
|
||||||
|
created_at text not null,
|
||||||
|
foreign key(thread_id) references conversation_threads(id) on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists conversation_participants (
|
||||||
|
thread_id text not null,
|
||||||
|
participant_id text not null,
|
||||||
|
participant_type text not null,
|
||||||
|
role text not null default 'member',
|
||||||
|
joined_at text not null,
|
||||||
|
primary key(thread_id, participant_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists thread_wait_states (
|
||||||
|
id text primary key,
|
||||||
|
thread_id text not null,
|
||||||
|
company_id text not null,
|
||||||
|
waiting_for text not null,
|
||||||
|
related_approval_id text null,
|
||||||
|
reason text not null,
|
||||||
|
created_at text not null,
|
||||||
|
resolved_at text null,
|
||||||
|
foreign key(thread_id) references conversation_threads(id) on delete cascade,
|
||||||
|
foreign key(company_id) references companies(id) on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists work_products (
|
||||||
|
id text primary key,
|
||||||
|
company_id text not null,
|
||||||
|
project_id text null,
|
||||||
|
task_id text null,
|
||||||
|
type text not null,
|
||||||
|
provider text not null,
|
||||||
|
external_id text null,
|
||||||
|
title text not null,
|
||||||
|
url text null,
|
||||||
|
status text not null default 'active',
|
||||||
|
review_state text not null default 'none',
|
||||||
|
is_primary integer not null default 0,
|
||||||
|
summary text null,
|
||||||
|
metadata_json text null,
|
||||||
|
created_by_run_id text null,
|
||||||
|
created_at text not null,
|
||||||
|
updated_at text not null,
|
||||||
|
foreign key(company_id) references companies(id) on delete cascade
|
||||||
|
);
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
seed_connectors(conn)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed_connectors(conn: &Connection) -> Result<()> {
|
||||||
|
let updated_at = now_iso()?;
|
||||||
|
let mut statement = conn.prepare(
|
||||||
|
r#"
|
||||||
|
insert into connectors (
|
||||||
|
id, label, description, status, command, version, auth_state,
|
||||||
|
last_checked_at, last_error, model, env_binding_text, notes, updated_at
|
||||||
|
)
|
||||||
|
values (?1, ?2, ?3, 'not_installed', '', null, 'unknown', null, null, null, '', '', ?4)
|
||||||
|
on conflict(id) do update set
|
||||||
|
label = excluded.label,
|
||||||
|
description = excluded.description,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
for connector in CONNECTOR_SEEDS {
|
||||||
|
statement.execute(params![
|
||||||
|
connector.id,
|
||||||
|
connector.label,
|
||||||
|
connector.description,
|
||||||
|
updated_at
|
||||||
|
])?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,711 @@
|
|||||||
|
use crate::schema;
|
||||||
|
use anyhow::{Context, Result, anyhow};
|
||||||
|
use domain::{
|
||||||
|
Activity, Agent, AgentMessage, Approval, ApprovalState, Company, Connector, Document, Goal,
|
||||||
|
ProfileSnapshot, Project, Run, Task, Workspace,
|
||||||
|
};
|
||||||
|
use rusqlite::{Connection, OptionalExtension, params};
|
||||||
|
use std::{
|
||||||
|
env,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
use support::{
|
||||||
|
paths::{ensure_profile_dir, profile_db_path},
|
||||||
|
time::now_iso,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const BOARD_READER_ID: &str = "__board__";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct NewCompanyInput {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub workspace_path: String,
|
||||||
|
pub execution_mode: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ProfileStore {
|
||||||
|
profile_dir: PathBuf,
|
||||||
|
db_path: PathBuf,
|
||||||
|
conn: Connection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProfileStore {
|
||||||
|
pub fn open(profile_dir: impl AsRef<Path>) -> Result<Self> {
|
||||||
|
let profile_dir = profile_dir.as_ref().to_path_buf();
|
||||||
|
ensure_profile_dir(&profile_dir)?;
|
||||||
|
let db_path = profile_db_path(&profile_dir);
|
||||||
|
let conn = Connection::open(&db_path)
|
||||||
|
.with_context(|| format!("failed to open SQLite database at {}", db_path.display()))?;
|
||||||
|
conn.busy_timeout(Duration::from_secs(5))?;
|
||||||
|
conn.execute_batch("pragma foreign_keys = on; pragma journal_mode = wal;")?;
|
||||||
|
schema::initialize(&conn)?;
|
||||||
|
Ok(Self {
|
||||||
|
profile_dir,
|
||||||
|
db_path,
|
||||||
|
conn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn profile_dir(&self) -> &Path {
|
||||||
|
&self.profile_dir
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn db_path(&self) -> &Path {
|
||||||
|
&self.db_path
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> Result<ProfileSnapshot> {
|
||||||
|
let companies = self.query_companies()?;
|
||||||
|
let preferred_company_id = self.setting("current_company_id")?;
|
||||||
|
let current_company_id = preferred_company_id
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.or_else(|| companies.first().map(|company| company.id.clone()));
|
||||||
|
|
||||||
|
let current_company_key = current_company_id.clone();
|
||||||
|
let current_ref = current_company_key.as_deref();
|
||||||
|
Ok(ProfileSnapshot {
|
||||||
|
current_company_id,
|
||||||
|
profile_path: self.profile_dir.display().to_string(),
|
||||||
|
theme: self
|
||||||
|
.setting("theme")?
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_else(|| "system".to_string()),
|
||||||
|
locale: self
|
||||||
|
.setting("locale")?
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_else(default_locale),
|
||||||
|
companies,
|
||||||
|
connectors: self.query_connectors()?,
|
||||||
|
workspaces: self.query_workspaces(current_ref)?,
|
||||||
|
agents: self.query_agents(current_ref)?,
|
||||||
|
goals: self.query_goals(current_ref)?,
|
||||||
|
projects: self.query_projects(current_ref)?,
|
||||||
|
tasks: self.query_tasks(current_ref)?,
|
||||||
|
approvals: self.query_approvals(current_ref)?,
|
||||||
|
runs: self.query_runs(current_ref)?,
|
||||||
|
activity: self.query_activity(current_ref)?,
|
||||||
|
agent_messages: self.query_messages(current_ref)?,
|
||||||
|
documents: self.query_documents(current_ref)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_company(&mut self, input: NewCompanyInput) -> Result<String> {
|
||||||
|
let name = input.name.trim();
|
||||||
|
if name.len() < 2 {
|
||||||
|
return Err(anyhow!("company name must be at least 2 characters"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let company_id = Uuid::new_v4().to_string();
|
||||||
|
let ts = now_iso()?;
|
||||||
|
self.conn.execute(
|
||||||
|
r#"
|
||||||
|
insert into companies (
|
||||||
|
id, name, description, status, auto_approve_hires,
|
||||||
|
review_deliverables, execution_mode, created_at, updated_at
|
||||||
|
) values (?1, ?2, ?3, 'active', 0, 0, ?4, ?5, ?5)
|
||||||
|
"#,
|
||||||
|
params![
|
||||||
|
company_id,
|
||||||
|
name,
|
||||||
|
input.description.trim(),
|
||||||
|
normalized_execution_mode(&input.execution_mode),
|
||||||
|
ts
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
self.set_current_company(&company_id)?;
|
||||||
|
|
||||||
|
if !input.workspace_path.trim().is_empty() {
|
||||||
|
self.conn.execute(
|
||||||
|
r#"
|
||||||
|
insert into workspaces (
|
||||||
|
id, company_id, name, local_path, repo_url, repo_ref,
|
||||||
|
project_id, state, is_primary, created_at, updated_at
|
||||||
|
) values (?1, ?2, ?3, ?4, '', '', null, 'ready', 1, ?5, ?5)
|
||||||
|
"#,
|
||||||
|
params![
|
||||||
|
Uuid::new_v4().to_string(),
|
||||||
|
company_id,
|
||||||
|
"Primary Workspace",
|
||||||
|
input.workspace_path.trim(),
|
||||||
|
ts
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.add_activity(
|
||||||
|
&company_id,
|
||||||
|
BOARD_READER_ID,
|
||||||
|
"created_company",
|
||||||
|
"companies",
|
||||||
|
&company_id,
|
||||||
|
name,
|
||||||
|
)?;
|
||||||
|
Ok(company_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_current_company(&mut self, company_id: &str) -> Result<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"insert into settings (key, value) values ('current_company_id', ?1) on conflict(key) do update set value = excluded.value",
|
||||||
|
params![company_id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decide_approval(
|
||||||
|
&mut self,
|
||||||
|
company_id: &str,
|
||||||
|
approval_id: &str,
|
||||||
|
state: ApprovalState,
|
||||||
|
note: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let ts = now_iso()?;
|
||||||
|
let tx = self.conn.transaction()?;
|
||||||
|
let approval = tx
|
||||||
|
.query_row(
|
||||||
|
"select type, related_agent_id from approvals where id = ?1 and company_id = ?2",
|
||||||
|
params![approval_id, company_id],
|
||||||
|
|row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.ok_or_else(|| anyhow!("approval {approval_id} was not found"))?;
|
||||||
|
|
||||||
|
tx.execute(
|
||||||
|
"update approvals set state = ?1, decision_note = ?2, updated_at = ?3 where id = ?4 and company_id = ?5",
|
||||||
|
params![state.as_str(), note.trim(), ts, approval_id, company_id],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
if approval.0 == "hire_agent"
|
||||||
|
&& let Some(agent_id) = approval.1
|
||||||
|
{
|
||||||
|
let next_status = match state {
|
||||||
|
ApprovalState::Approved => Some("idle"),
|
||||||
|
ApprovalState::Rejected => Some("terminated"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(status) = next_status {
|
||||||
|
tx.execute(
|
||||||
|
"update agents set status = ?1, updated_at = ?2 where id = ?3 and company_id = ?4",
|
||||||
|
params![status, ts, agent_id, company_id],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit()?;
|
||||||
|
self.add_activity(
|
||||||
|
company_id,
|
||||||
|
BOARD_READER_ID,
|
||||||
|
format!("approval_{}", state.as_str()).as_str(),
|
||||||
|
"approvals",
|
||||||
|
approval_id,
|
||||||
|
note.trim(),
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_message_read(&mut self, company_id: &str, message_id: &str) -> Result<()> {
|
||||||
|
let ts = now_iso()?;
|
||||||
|
self.conn.execute(
|
||||||
|
r#"
|
||||||
|
insert into agent_message_reads (message_id, agent_id, read_at)
|
||||||
|
select id, ?1, ?2 from agent_messages where id = ?3 and company_id = ?4
|
||||||
|
on conflict(message_id, agent_id) do update set read_at = excluded.read_at
|
||||||
|
"#,
|
||||||
|
params![BOARD_READER_ID, ts, message_id, company_id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_board_message(&mut self, company_id: &str, body: &str) -> Result<String> {
|
||||||
|
let body = body.trim();
|
||||||
|
if body.is_empty() {
|
||||||
|
return Err(anyhow!("message body cannot be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let message_id = Uuid::new_v4().to_string();
|
||||||
|
let ts = now_iso()?;
|
||||||
|
self.conn.execute(
|
||||||
|
r#"
|
||||||
|
insert into agent_messages (
|
||||||
|
id, company_id, from_agent_id, to_agent_id, sender_kind, channel,
|
||||||
|
channel_target_id, subject, body, priority, read_at, parent_message_id,
|
||||||
|
attachments_json, created_at
|
||||||
|
) values (?1, ?2, ?3, null, 'human', 'company', null, ?4, ?5, 'normal', null, null, '[]', ?6)
|
||||||
|
"#,
|
||||||
|
params![message_id, company_id, BOARD_READER_ID, subject_for(body), body, ts],
|
||||||
|
)?;
|
||||||
|
self.add_activity(
|
||||||
|
company_id,
|
||||||
|
BOARD_READER_ID,
|
||||||
|
"sent_message",
|
||||||
|
"agent_messages",
|
||||||
|
&message_id,
|
||||||
|
body,
|
||||||
|
)?;
|
||||||
|
Ok(message_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_document(
|
||||||
|
&mut self,
|
||||||
|
company_id: &str,
|
||||||
|
title: &str,
|
||||||
|
content: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let title = title.trim();
|
||||||
|
if title.is_empty() {
|
||||||
|
return Err(anyhow!("document title cannot be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let document_id = Uuid::new_v4().to_string();
|
||||||
|
let ts = now_iso()?;
|
||||||
|
self.conn.execute(
|
||||||
|
r#"
|
||||||
|
insert into documents (
|
||||||
|
id, company_id, type, title, content, author_agent_id, reviewer_agent_id,
|
||||||
|
project_id, goal_id, parent_doc_id, version, status, tags_json,
|
||||||
|
latest_revision_id, source_run_id, created_at, updated_at
|
||||||
|
) values (?1, ?2, 'status_report', ?3, ?4, null, null, null, null, null, 1, 'draft', '[]', null, null, ?5, ?5)
|
||||||
|
"#,
|
||||||
|
params![document_id, company_id, title, content.trim(), ts],
|
||||||
|
)?;
|
||||||
|
self.add_activity(
|
||||||
|
company_id,
|
||||||
|
BOARD_READER_ID,
|
||||||
|
"created_document",
|
||||||
|
"documents",
|
||||||
|
&document_id,
|
||||||
|
title,
|
||||||
|
)?;
|
||||||
|
Ok(document_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_activity(
|
||||||
|
&self,
|
||||||
|
company_id: &str,
|
||||||
|
actor: &str,
|
||||||
|
action: &str,
|
||||||
|
entity_type: &str,
|
||||||
|
entity_id: &str,
|
||||||
|
detail: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
r#"
|
||||||
|
insert into activity (id, company_id, actor, action, entity_type, entity_id, detail, created_at)
|
||||||
|
values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||||
|
"#,
|
||||||
|
params![
|
||||||
|
Uuid::new_v4().to_string(),
|
||||||
|
company_id,
|
||||||
|
actor,
|
||||||
|
action,
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
detail,
|
||||||
|
now_iso()?
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setting(&self, key: &str) -> Result<Option<String>> {
|
||||||
|
self.conn
|
||||||
|
.query_row(
|
||||||
|
"select value from settings where key = ?1",
|
||||||
|
params![key],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.context("failed to read settings row")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_companies(&self) -> Result<Vec<Company>> {
|
||||||
|
query_many(
|
||||||
|
&self.conn,
|
||||||
|
"select id, name, description, status, auto_approve_hires, review_deliverables, execution_mode, created_at, updated_at from companies order by created_at asc",
|
||||||
|
[],
|
||||||
|
|row| {
|
||||||
|
Ok(Company {
|
||||||
|
id: row.get(0)?,
|
||||||
|
name: row.get(1)?,
|
||||||
|
description: row.get(2)?,
|
||||||
|
status: row.get(3)?,
|
||||||
|
auto_approve_hires: int_to_bool(row.get(4)?),
|
||||||
|
review_deliverables: int_to_bool(row.get(5)?),
|
||||||
|
execution_mode: row.get(6)?,
|
||||||
|
created_at: row.get(7)?,
|
||||||
|
updated_at: row.get(8)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_connectors(&self) -> Result<Vec<Connector>> {
|
||||||
|
query_many(
|
||||||
|
&self.conn,
|
||||||
|
"select id, label, description, status, version, auth_state, last_checked_at, last_error, model, env_binding_text, notes from connectors order by label asc",
|
||||||
|
[],
|
||||||
|
|row| {
|
||||||
|
Ok(Connector {
|
||||||
|
id: row.get(0)?,
|
||||||
|
label: row.get(1)?,
|
||||||
|
description: row.get(2)?,
|
||||||
|
status: row.get(3)?,
|
||||||
|
version: row.get(4)?,
|
||||||
|
auth_state: row.get(5)?,
|
||||||
|
last_checked_at: row.get(6)?,
|
||||||
|
last_error: row.get(7)?,
|
||||||
|
model: row.get(8)?,
|
||||||
|
env_binding_text: row.get(9)?,
|
||||||
|
notes: row.get(10)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_workspaces(&self, company_id: Option<&str>) -> Result<Vec<Workspace>> {
|
||||||
|
match company_id {
|
||||||
|
Some(company_id) => query_many(
|
||||||
|
&self.conn,
|
||||||
|
"select id, company_id, name, local_path, repo_url, repo_ref, project_id, state, is_primary, created_at, updated_at from workspaces where company_id = ?1 order by is_primary desc, created_at asc",
|
||||||
|
[company_id],
|
||||||
|
|row| {
|
||||||
|
Ok(Workspace {
|
||||||
|
id: row.get(0)?,
|
||||||
|
company_id: row.get(1)?,
|
||||||
|
name: row.get(2)?,
|
||||||
|
local_path: row.get(3)?,
|
||||||
|
repo_url: row.get(4)?,
|
||||||
|
repo_ref: row.get(5)?,
|
||||||
|
project_id: row.get(6)?,
|
||||||
|
state: row.get(7)?,
|
||||||
|
is_primary: int_to_bool(row.get(8)?),
|
||||||
|
created_at: row.get(9)?,
|
||||||
|
updated_at: row.get(10)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
),
|
||||||
|
None => Ok(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_agents(&self, company_id: Option<&str>) -> Result<Vec<Agent>> {
|
||||||
|
match company_id {
|
||||||
|
Some(company_id) => query_many(
|
||||||
|
&self.conn,
|
||||||
|
"select id, company_id, name, role, title, department, status, reports_to, connector_id, workspace_id, model, capabilities, budget_monthly_usd, spent_monthly_usd, created_at, updated_at from agents where company_id = ?1 order by created_at asc",
|
||||||
|
[company_id],
|
||||||
|
|row| {
|
||||||
|
Ok(Agent {
|
||||||
|
id: row.get(0)?,
|
||||||
|
company_id: row.get(1)?,
|
||||||
|
name: row.get(2)?,
|
||||||
|
role: row.get(3)?,
|
||||||
|
title: row.get(4)?,
|
||||||
|
department: row.get(5)?,
|
||||||
|
status: row.get(6)?,
|
||||||
|
reports_to: row.get(7)?,
|
||||||
|
connector_id: row.get(8)?,
|
||||||
|
workspace_id: row.get(9)?,
|
||||||
|
model: row.get(10)?,
|
||||||
|
capabilities: row.get(11)?,
|
||||||
|
budget_monthly_usd: row.get(12)?,
|
||||||
|
spent_monthly_usd: row.get(13)?,
|
||||||
|
created_at: row.get(14)?,
|
||||||
|
updated_at: row.get(15)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
),
|
||||||
|
None => Ok(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_goals(&self, company_id: Option<&str>) -> Result<Vec<Goal>> {
|
||||||
|
match company_id {
|
||||||
|
Some(company_id) => query_many(
|
||||||
|
&self.conn,
|
||||||
|
"select id, company_id, title, description, parent_id, owner_agent_id, status, created_at, updated_at from goals where company_id = ?1 order by created_at desc",
|
||||||
|
[company_id],
|
||||||
|
|row| {
|
||||||
|
Ok(Goal {
|
||||||
|
id: row.get(0)?,
|
||||||
|
company_id: row.get(1)?,
|
||||||
|
title: row.get(2)?,
|
||||||
|
description: row.get(3)?,
|
||||||
|
parent_id: row.get(4)?,
|
||||||
|
owner_agent_id: row.get(5)?,
|
||||||
|
status: row.get(6)?,
|
||||||
|
created_at: row.get(7)?,
|
||||||
|
updated_at: row.get(8)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
),
|
||||||
|
None => Ok(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_projects(&self, company_id: Option<&str>) -> Result<Vec<Project>> {
|
||||||
|
match company_id {
|
||||||
|
Some(company_id) => query_many(
|
||||||
|
&self.conn,
|
||||||
|
"select id, company_id, goal_id, name, description, lead_agent_id, status, target_date, created_at, updated_at from projects where company_id = ?1 order by created_at desc",
|
||||||
|
[company_id],
|
||||||
|
|row| {
|
||||||
|
Ok(Project {
|
||||||
|
id: row.get(0)?,
|
||||||
|
company_id: row.get(1)?,
|
||||||
|
goal_id: row.get(2)?,
|
||||||
|
name: row.get(3)?,
|
||||||
|
description: row.get(4)?,
|
||||||
|
lead_agent_id: row.get(5)?,
|
||||||
|
status: row.get(6)?,
|
||||||
|
target_date: row.get(7)?,
|
||||||
|
created_at: row.get(8)?,
|
||||||
|
updated_at: row.get(9)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
),
|
||||||
|
None => Ok(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_tasks(&self, company_id: Option<&str>) -> Result<Vec<Task>> {
|
||||||
|
match company_id {
|
||||||
|
Some(company_id) => query_many(
|
||||||
|
&self.conn,
|
||||||
|
"select id, company_id, project_id, goal_id, parent_id, title, description, assignee_agent_id, workspace_id, status, priority, active_run_id, cost_usd, task_type, requires_user_review, started_at, completed_at, cancelled_at, created_at, updated_at from tasks where company_id = ?1 order by created_at desc",
|
||||||
|
[company_id],
|
||||||
|
|row| {
|
||||||
|
Ok(Task {
|
||||||
|
id: row.get(0)?,
|
||||||
|
company_id: row.get(1)?,
|
||||||
|
project_id: row.get(2)?,
|
||||||
|
goal_id: row.get(3)?,
|
||||||
|
parent_id: row.get(4)?,
|
||||||
|
title: row.get(5)?,
|
||||||
|
description: row.get(6)?,
|
||||||
|
assignee_agent_id: row.get(7)?,
|
||||||
|
workspace_id: row.get(8)?,
|
||||||
|
status: row.get(9)?,
|
||||||
|
priority: row.get(10)?,
|
||||||
|
active_run_id: row.get(11)?,
|
||||||
|
cost_usd: row.get(12)?,
|
||||||
|
task_type: row.get(13)?,
|
||||||
|
requires_user_review: int_to_bool(row.get(14)?),
|
||||||
|
started_at: row.get(15)?,
|
||||||
|
completed_at: row.get(16)?,
|
||||||
|
cancelled_at: row.get(17)?,
|
||||||
|
created_at: row.get(18)?,
|
||||||
|
updated_at: row.get(19)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
),
|
||||||
|
None => Ok(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_approvals(&self, company_id: Option<&str>) -> Result<Vec<Approval>> {
|
||||||
|
match company_id {
|
||||||
|
Some(company_id) => query_many(
|
||||||
|
&self.conn,
|
||||||
|
"select id, company_id, related_task_id, related_agent_id, requested_by_agent_id, type, payload_summary, impact_summary, payload_json, decision_note, state, created_at, updated_at from approvals where company_id = ?1 order by created_at desc",
|
||||||
|
[company_id],
|
||||||
|
|row| {
|
||||||
|
Ok(Approval {
|
||||||
|
id: row.get(0)?,
|
||||||
|
company_id: row.get(1)?,
|
||||||
|
related_task_id: row.get(2)?,
|
||||||
|
related_agent_id: row.get(3)?,
|
||||||
|
requested_by_agent_id: row.get(4)?,
|
||||||
|
approval_type: row.get(5)?,
|
||||||
|
payload_summary: row.get(6)?,
|
||||||
|
impact_summary: row.get(7)?,
|
||||||
|
payload_json: row.get(8)?,
|
||||||
|
decision_note: row.get(9)?,
|
||||||
|
state: row.get(10)?,
|
||||||
|
created_at: row.get(11)?,
|
||||||
|
updated_at: row.get(12)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
),
|
||||||
|
None => Ok(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_runs(&self, company_id: Option<&str>) -> Result<Vec<Run>> {
|
||||||
|
match company_id {
|
||||||
|
Some(company_id) => query_many(
|
||||||
|
&self.conn,
|
||||||
|
"select id, company_id, task_id, agent_id, workspace_id, connector_id, status, summary, error_message, model, cost_usd, started_at, finished_at, created_at, updated_at from runs where company_id = ?1 order by created_at desc limit 50",
|
||||||
|
[company_id],
|
||||||
|
|row| {
|
||||||
|
Ok(Run {
|
||||||
|
id: row.get(0)?,
|
||||||
|
company_id: row.get(1)?,
|
||||||
|
task_id: row.get(2)?,
|
||||||
|
agent_id: row.get(3)?,
|
||||||
|
workspace_id: row.get(4)?,
|
||||||
|
connector_id: row.get(5)?,
|
||||||
|
status: row.get(6)?,
|
||||||
|
summary: row.get(7)?,
|
||||||
|
error_message: row.get(8)?,
|
||||||
|
model: row.get(9)?,
|
||||||
|
cost_usd: row.get(10)?,
|
||||||
|
started_at: row.get(11)?,
|
||||||
|
finished_at: row.get(12)?,
|
||||||
|
created_at: row.get(13)?,
|
||||||
|
updated_at: row.get(14)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
),
|
||||||
|
None => Ok(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_activity(&self, company_id: Option<&str>) -> Result<Vec<Activity>> {
|
||||||
|
match company_id {
|
||||||
|
Some(company_id) => query_many(
|
||||||
|
&self.conn,
|
||||||
|
"select id, company_id, actor, action, entity_type, entity_id, detail, created_at from activity where company_id = ?1 order by created_at desc limit 50",
|
||||||
|
[company_id],
|
||||||
|
|row| {
|
||||||
|
Ok(Activity {
|
||||||
|
id: row.get(0)?,
|
||||||
|
company_id: row.get(1)?,
|
||||||
|
actor: row.get(2)?,
|
||||||
|
action: row.get(3)?,
|
||||||
|
entity_type: row.get(4)?,
|
||||||
|
entity_id: row.get(5)?,
|
||||||
|
detail: row.get(6)?,
|
||||||
|
created_at: row.get(7)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
),
|
||||||
|
None => Ok(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_messages(&self, company_id: Option<&str>) -> Result<Vec<AgentMessage>> {
|
||||||
|
match company_id {
|
||||||
|
Some(company_id) => query_many(
|
||||||
|
&self.conn,
|
||||||
|
r#"
|
||||||
|
select
|
||||||
|
m.id,
|
||||||
|
m.company_id,
|
||||||
|
m.from_agent_id,
|
||||||
|
m.to_agent_id,
|
||||||
|
m.sender_kind,
|
||||||
|
m.channel,
|
||||||
|
m.channel_target_id,
|
||||||
|
m.subject,
|
||||||
|
m.body,
|
||||||
|
m.priority,
|
||||||
|
coalesce(r.read_at, m.read_at) as effective_read_at,
|
||||||
|
m.parent_message_id,
|
||||||
|
m.attachments_json,
|
||||||
|
m.created_at
|
||||||
|
from agent_messages m
|
||||||
|
left join agent_message_reads r
|
||||||
|
on r.message_id = m.id and r.agent_id = ?1
|
||||||
|
where m.company_id = ?2
|
||||||
|
order by m.created_at desc
|
||||||
|
limit 200
|
||||||
|
"#,
|
||||||
|
[BOARD_READER_ID, company_id],
|
||||||
|
|row| {
|
||||||
|
Ok(AgentMessage {
|
||||||
|
id: row.get(0)?,
|
||||||
|
company_id: row.get(1)?,
|
||||||
|
from_agent_id: row.get(2)?,
|
||||||
|
to_agent_id: row.get(3)?,
|
||||||
|
sender_kind: row.get(4)?,
|
||||||
|
channel: row.get(5)?,
|
||||||
|
channel_target_id: row.get(6)?,
|
||||||
|
subject: row.get(7)?,
|
||||||
|
body: row.get(8)?,
|
||||||
|
priority: row.get(9)?,
|
||||||
|
read_at: row.get(10)?,
|
||||||
|
parent_message_id: row.get(11)?,
|
||||||
|
attachments_json: row.get(12)?,
|
||||||
|
created_at: row.get(13)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
),
|
||||||
|
None => Ok(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_documents(&self, company_id: Option<&str>) -> Result<Vec<Document>> {
|
||||||
|
match company_id {
|
||||||
|
Some(company_id) => query_many(
|
||||||
|
&self.conn,
|
||||||
|
"select id, company_id, type, title, content, author_agent_id, reviewer_agent_id, project_id, goal_id, parent_doc_id, version, status, tags_json, latest_revision_id, created_at, updated_at from documents where company_id = ?1 order by updated_at desc",
|
||||||
|
[company_id],
|
||||||
|
|row| {
|
||||||
|
Ok(Document {
|
||||||
|
id: row.get(0)?,
|
||||||
|
company_id: row.get(1)?,
|
||||||
|
document_type: row.get(2)?,
|
||||||
|
title: row.get(3)?,
|
||||||
|
content: row.get(4)?,
|
||||||
|
author_agent_id: row.get(5)?,
|
||||||
|
reviewer_agent_id: row.get(6)?,
|
||||||
|
project_id: row.get(7)?,
|
||||||
|
goal_id: row.get(8)?,
|
||||||
|
parent_doc_id: row.get(9)?,
|
||||||
|
version: row.get(10)?,
|
||||||
|
status: row.get(11)?,
|
||||||
|
tags_json: row.get(12)?,
|
||||||
|
latest_revision_id: row.get(13)?,
|
||||||
|
created_at: row.get(14)?,
|
||||||
|
updated_at: row.get(15)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
),
|
||||||
|
None => Ok(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_many<T, P, F>(conn: &Connection, sql: &str, params: P, mut mapper: F) -> Result<Vec<T>>
|
||||||
|
where
|
||||||
|
P: rusqlite::Params,
|
||||||
|
F: FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
|
||||||
|
{
|
||||||
|
let mut statement = conn.prepare(sql)?;
|
||||||
|
let rows = statement.query_map(params, |row| mapper(row))?;
|
||||||
|
let mut values = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
values.push(row?);
|
||||||
|
}
|
||||||
|
Ok(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn int_to_bool(value: i64) -> bool {
|
||||||
|
value != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subject_for(body: &str) -> String {
|
||||||
|
let line = body.lines().next().unwrap_or(body).trim();
|
||||||
|
let mut subject = line.chars().take(72).collect::<String>();
|
||||||
|
if subject.is_empty() {
|
||||||
|
subject = "Board update".to_string();
|
||||||
|
}
|
||||||
|
subject
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_execution_mode(value: &str) -> &str {
|
||||||
|
match value {
|
||||||
|
"ai_cofounder" => "ai_cofounder",
|
||||||
|
_ => "ai_ceo",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_locale() -> String {
|
||||||
|
match env::var("LANG") {
|
||||||
|
Ok(lang) if lang.to_ascii_lowercase().contains("zh") => "zh".to_string(),
|
||||||
|
_ => "en".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
use domain::ApprovalState;
|
||||||
|
use storage::{NewCompanyInput, ProfileStore};
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_store_bootstraps_and_round_trips_core_flows() {
|
||||||
|
let sandbox = tempdir().expect("temp dir");
|
||||||
|
let mut store = ProfileStore::open(sandbox.path()).expect("open store");
|
||||||
|
|
||||||
|
let company_id = store
|
||||||
|
.create_company(NewCompanyInput {
|
||||||
|
name: "Orbit Lab".to_string(),
|
||||||
|
description: "Semantic communication thesis program".to_string(),
|
||||||
|
workspace_path: "/tmp/orbit-lab".to_string(),
|
||||||
|
execution_mode: "ai_ceo".to_string(),
|
||||||
|
})
|
||||||
|
.expect("create company");
|
||||||
|
|
||||||
|
let snapshot = store.snapshot().expect("snapshot");
|
||||||
|
assert_eq!(snapshot.companies.len(), 1);
|
||||||
|
assert_eq!(snapshot.workspaces.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.current_company_id.as_deref(),
|
||||||
|
Some(company_id.as_str())
|
||||||
|
);
|
||||||
|
assert_eq!(snapshot.connectors.len(), 26);
|
||||||
|
|
||||||
|
let approval_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let ts = support::time::now_iso().expect("timestamp");
|
||||||
|
let conn = rusqlite::Connection::open(store.db_path()).expect("open raw db");
|
||||||
|
conn.execute(
|
||||||
|
"insert into approvals (id, company_id, related_task_id, requested_by_agent_id, related_agent_id, type, payload_summary, impact_summary, payload_json, decision_note, state, created_at, updated_at) values (?1, ?2, null, null, null, 'strategy_review', 'Review launch plan', 'Affects milestone timing', null, '', 'pending', ?3, ?3)",
|
||||||
|
rusqlite::params![approval_id, company_id, ts],
|
||||||
|
)
|
||||||
|
.expect("insert approval");
|
||||||
|
|
||||||
|
store
|
||||||
|
.decide_approval(
|
||||||
|
&company_id,
|
||||||
|
&approval_id,
|
||||||
|
ApprovalState::Approved,
|
||||||
|
"Ship the native shell.",
|
||||||
|
)
|
||||||
|
.expect("decide approval");
|
||||||
|
store
|
||||||
|
.send_board_message(&company_id, "Board update: thesis plan is in review.")
|
||||||
|
.expect("send message");
|
||||||
|
store
|
||||||
|
.create_document(
|
||||||
|
&company_id,
|
||||||
|
"Weekly status",
|
||||||
|
"Native Rust shell connected to the real profile.",
|
||||||
|
)
|
||||||
|
.expect("create document");
|
||||||
|
|
||||||
|
let after = store.snapshot().expect("snapshot after writes");
|
||||||
|
assert_eq!(after.pending_approvals(), 0);
|
||||||
|
assert_eq!(after.agent_messages.len(), 1);
|
||||||
|
assert_eq!(after.documents.len(), 1);
|
||||||
|
assert!(!after.activity.is_empty());
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "support"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
dirs.workspace = true
|
||||||
|
time.workspace = true
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod paths;
|
||||||
|
pub mod time;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use std::{
|
||||||
|
env, fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const APP_NAME: &str = "AgentCompany";
|
||||||
|
pub const PROFILE_ENV: &str = "AGENTCOMPANY_PROFILE_DIR";
|
||||||
|
|
||||||
|
pub fn default_profile_dir() -> Result<PathBuf> {
|
||||||
|
dirs::data_dir()
|
||||||
|
.map(|path| path.join(APP_NAME).join("profile"))
|
||||||
|
.ok_or_else(|| anyhow!("OS data directory is unavailable"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_profile_dir() -> Result<PathBuf> {
|
||||||
|
match env::var(PROFILE_ENV) {
|
||||||
|
Ok(value) if !value.trim().is_empty() => Ok(PathBuf::from(value)),
|
||||||
|
_ => default_profile_dir(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn profile_db_path(profile_dir: &Path) -> PathBuf {
|
||||||
|
profile_dir.join("profile.sqlite")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_profile_dir(profile_dir: &Path) -> Result<()> {
|
||||||
|
fs::create_dir_all(profile_dir)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||||
|
|
||||||
|
pub fn now_iso() -> Result<String> {
|
||||||
|
OffsetDateTime::now_utc()
|
||||||
|
.format(&Rfc3339)
|
||||||
|
.context("failed to format UTC timestamp")
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "stable"
|
||||||
Reference in new issue
Block a user