17
app/src-tauri/.cargo/config.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
# Intentionally NO `linker` override here.
|
||||
#
|
||||
# A previous version pinned the MSVC linker to an absolute, version-stamped path
|
||||
# (…/MSVC/14.44.35207/…/link.exe) so Git Bash's GNU coreutils `link` couldn't
|
||||
# shadow MSVC's linker on one local machine. That pin rotted: windows-latest CI
|
||||
# ships a different MSVC toolset, and so does any dev box without that exact
|
||||
# version, so cargo failed with:
|
||||
# error: linker `…14.44.35207…\link.exe` not found
|
||||
# note: The system cannot find the path specified. (os error 3)
|
||||
#
|
||||
# rustc/cc auto-detect the MSVC linker via vswhere on a properly installed
|
||||
# toolchain — windows-latest works out of the box, no override needed.
|
||||
#
|
||||
# If Git's `link` shadows MSVC's linker locally, fix it in the ENVIRONMENT, not
|
||||
# here: build from a "Developer Command Prompt for VS 2022" (or run
|
||||
# vcvarsall.bat) so MSVC's bin precedes Git on PATH. Do NOT re-pin an absolute,
|
||||
# version-stamped path — it breaks CI and every other machine.
|
||||
6185
app/src-tauri/Cargo.lock
generated
Normal file
29
app/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "waggle"
|
||||
version = "0.2.0"
|
||||
description = "Waggle - Your personal AI agent swarm"
|
||||
authors = ["Marko Markovic"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "waggle_lib"
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
urlencoding = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
3
app/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
12
app/src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:default",
|
||||
"notification:default",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister"
|
||||
]
|
||||
}
|
||||
1
app/src-tauri/gen/schemas/acl-manifests.json
Normal file
1
app/src-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities","local":true,"windows":["main"],"permissions":["core:default","shell:default","notification:default","global-shortcut:allow-register","global-shortcut:allow-unregister"]}}
|
||||
2990
app/src-tauri/gen/schemas/desktop-schema.json
Normal file
2990
app/src-tauri/gen/schemas/windows-schema.json
Normal file
BIN
app/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
app/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
app/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
app/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
BIN
app/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
BIN
app/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
app/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
BIN
app/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 16 KiB |
BIN
app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 28 KiB |
BIN
app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 42 KiB |
BIN
app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
BIN
app/src-tauri/icons/icon.icns
Normal file
BIN
app/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
app/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 611 B |
BIN
app/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
app/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
69
app/src-tauri/nsis/installer.nsi
Normal file
@@ -0,0 +1,69 @@
|
||||
; ─── Waggle NSIS Installer Template ──────────────────────────────────────────
|
||||
;
|
||||
; Custom hooks for the Tauri NSIS installer:
|
||||
; 1. Welcome message with Waggle branding
|
||||
; 2. Desktop shortcut creation
|
||||
; 3. Start Menu entry
|
||||
; 4. "Launch Waggle" on finish
|
||||
; 5. Uninstaller with optional ~/.waggle/ data removal
|
||||
;
|
||||
; Tauri injects NSIS defines: PRODUCT_NAME, PRODUCT_VERSION, MAINBINARYNAME,
|
||||
; DEFAULT_INSTALL_DIR. Autostart is handled by tauri-plugin-autostart at
|
||||
; runtime, not by the installer.
|
||||
;
|
||||
; Reference: https://tauri.app/distribute/windows-installer/#nsis
|
||||
; ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
InstallDir "${DEFAULT_INSTALL_DIR}"
|
||||
|
||||
!macro NSIS_HOOK_PREINSTALL
|
||||
DetailPrint "Installing ${PRODUCT_NAME} v${PRODUCT_VERSION}..."
|
||||
DetailPrint "Your personal AI agent workspace — powered by Waggle."
|
||||
!macroend
|
||||
|
||||
!macro NSIS_HOOK_POSTINSTALL
|
||||
; ── Desktop shortcut ──────────────────────────────────────────────────────
|
||||
CreateShortcut "$DESKTOP\${PRODUCT_NAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" \
|
||||
"" "$INSTDIR\${MAINBINARYNAME}.exe" 0
|
||||
DetailPrint "Desktop shortcut created."
|
||||
|
||||
; ── Start Menu entry ──────────────────────────────────────────────────────
|
||||
CreateDirectory "$SMPROGRAMS\${PRODUCT_NAME}"
|
||||
CreateShortcut "$SMPROGRAMS\${PRODUCT_NAME}\${PRODUCT_NAME}.lnk" \
|
||||
"$INSTDIR\${MAINBINARYNAME}.exe" "" "$INSTDIR\${MAINBINARYNAME}.exe" 0
|
||||
CreateShortcut "$SMPROGRAMS\${PRODUCT_NAME}\Uninstall ${PRODUCT_NAME}.lnk" \
|
||||
"$INSTDIR\uninstall.exe" "" "$INSTDIR\uninstall.exe" 0
|
||||
DetailPrint "Start Menu entry created."
|
||||
|
||||
; ── Launch after install ──────────────────────────────────────────────────
|
||||
Exec '"$INSTDIR\${MAINBINARYNAME}.exe"'
|
||||
DetailPrint "Launching ${PRODUCT_NAME}..."
|
||||
!macroend
|
||||
|
||||
!macro NSIS_HOOK_POSTUNINSTALL
|
||||
; ── Remove desktop shortcut ─────────────────────────────────────────────
|
||||
Delete "$DESKTOP\${PRODUCT_NAME}.lnk"
|
||||
|
||||
; ── Remove Start Menu entries ───────────────────────────────────────────
|
||||
Delete "$SMPROGRAMS\${PRODUCT_NAME}\${PRODUCT_NAME}.lnk"
|
||||
Delete "$SMPROGRAMS\${PRODUCT_NAME}\Uninstall ${PRODUCT_NAME}.lnk"
|
||||
RMDir "$SMPROGRAMS\${PRODUCT_NAME}"
|
||||
|
||||
; ── Ask about user data removal ─────────────────────────────────────────
|
||||
MessageBox MB_YESNO|MB_ICONQUESTION \
|
||||
"Waggle stores your data (agents, memories, configuration) in:$\r$\n$\r$\n\
|
||||
$PROFILE\.waggle$\r$\n$\r$\n\
|
||||
Do you want to remove this data as well?$\r$\n$\r$\n\
|
||||
Choose $\"Yes$\" to delete all data, or $\"No$\" to keep it for future use." \
|
||||
IDYES removeData IDNO skipData
|
||||
|
||||
removeData:
|
||||
RMDir /r "$PROFILE\.waggle"
|
||||
DetailPrint "User data removed: $PROFILE\.waggle"
|
||||
Goto doneData
|
||||
|
||||
skipData:
|
||||
DetailPrint "User data preserved: $PROFILE\.waggle"
|
||||
|
||||
doneData:
|
||||
!macroend
|
||||
0
app/src-tauri/resources/.gitkeep
Normal file
268
app/src-tauri/src/commands/agent.rs
Normal file
@@ -0,0 +1,268 @@
|
||||
// CC Sesija A §2.1 Task A3 — agent loop streaming Tauri command.
|
||||
//
|
||||
// Brief: briefs/2026-04-30-cc-sesija-A-waggle-apps-web-integration.md §2.1 Task A3
|
||||
//
|
||||
// Pattern: command returns a request_id immediately; tokio task POSTs to the
|
||||
// sidecar's `/api/chat` SSE endpoint, parses each event block, and emits
|
||||
// `agent-stream-{request_id}` to the webview per chunk. End-of-stream emits
|
||||
// `agent-stream-{request_id}-end` with either `{ ok: true }` or `{ error, ... }`.
|
||||
//
|
||||
// The webview's tauri-bindings.runAgentQuery() returns the request_id + an
|
||||
// unlisten handle so React components can subscribe per-conversation without
|
||||
// global state.
|
||||
//
|
||||
// A3.1 follow-up tracking:
|
||||
// - sidecar `/api/chat` calls `runAgentLoop`, not `runRetrievalAgentLoop`
|
||||
// as the brief requested. Faza 1's runRetrievalAgentLoop with shape
|
||||
// selection is not yet wired into the chat path. The `shape` param is
|
||||
// accepted here and passed through the body so a future sidecar patch
|
||||
// can read it without changing this command's surface. Document in
|
||||
// A3.1 follow-up.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::service::ServiceState;
|
||||
|
||||
const STREAM_TIMEOUT_SECS: u64 = 300;
|
||||
|
||||
/// Start an agent query. Returns a request_id; chunks arrive via the
|
||||
/// `agent-stream-{request_id}` Tauri event, end via `agent-stream-{request_id}-end`.
|
||||
#[tauri::command]
|
||||
pub async fn run_agent_query(
|
||||
app: AppHandle,
|
||||
state: State<'_, ServiceState>,
|
||||
query: String,
|
||||
shape: Option<String>,
|
||||
workspace_id: Option<String>,
|
||||
persona: Option<String>,
|
||||
model: Option<String>,
|
||||
session: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let request_id = format!("agent-{}", Uuid::new_v4());
|
||||
let port = state.port;
|
||||
let app_clone = app.clone();
|
||||
let req_id_clone = request_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = stream_chat(
|
||||
app_clone,
|
||||
port,
|
||||
req_id_clone,
|
||||
query,
|
||||
shape,
|
||||
workspace_id,
|
||||
persona,
|
||||
model,
|
||||
session,
|
||||
)
|
||||
.await
|
||||
{
|
||||
// The end event is already emitted from inside stream_chat on error
|
||||
// paths; this stderr is a developer-facing breadcrumb only.
|
||||
eprintln!("[agent] run_agent_query stream task error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(request_id)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn stream_chat(
|
||||
app: AppHandle,
|
||||
port: u16,
|
||||
request_id: String,
|
||||
query: String,
|
||||
shape: Option<String>,
|
||||
workspace_id: Option<String>,
|
||||
persona: Option<String>,
|
||||
model: Option<String>,
|
||||
session: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
// A3.1 (2026-04-30): re-pointed from /api/chat to /api/agent/run.
|
||||
// /api/agent/run is the dedicated shape-aware structured-retrieval
|
||||
// endpoint (runRetrievalAgentLoop) — distinct from /api/chat which is
|
||||
// for conversational multi-turn dialogue (runAgentLoop). Shape now flows
|
||||
// end-to-end: Tauri body field "shape" → sidecar promptShapeOverride
|
||||
// → runRetrievalAgentLoop pickShape().
|
||||
//
|
||||
// The TS binding's `session` arg is intentionally ignored here:
|
||||
// /api/agent/run is one-shot (no persistent session), so passing
|
||||
// session through would just be dead weight. Binding signature stays
|
||||
// stable so callers don't break.
|
||||
let url = format!("http://127.0.0.1:{}/api/agent/run", port);
|
||||
let mut body = json!({ "question": query });
|
||||
if let Some(ws) = workspace_id {
|
||||
body["workspace"] = json!(ws);
|
||||
}
|
||||
if let Some(p) = persona {
|
||||
body["persona"] = json!(p);
|
||||
}
|
||||
if let Some(m) = model {
|
||||
body["model"] = json!(m);
|
||||
}
|
||||
if let Some(sh) = shape {
|
||||
body["shape"] = json!(sh);
|
||||
}
|
||||
// `session` arg accepted by the Tauri command for binding-stability but
|
||||
// not threaded into /api/agent/run (one-shot). Suppress unused-warn.
|
||||
let _ = session;
|
||||
|
||||
let event_name = format!("agent-stream-{}", request_id);
|
||||
let end_event = format!("agent-stream-{}-end", request_id);
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(STREAM_TIMEOUT_SECS))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let _ = app.emit(
|
||||
&end_event,
|
||||
json!({ "error": format!("client build failed: {}", e) }),
|
||||
);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let mut resp = match client.post(&url).json(&body).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let _ = app.emit(
|
||||
&end_event,
|
||||
json!({ "error": format!("HTTP POST failed: {}", e) }),
|
||||
);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
let _ = app.emit(
|
||||
&end_event,
|
||||
json!({
|
||||
"error": format!("sidecar returned {}", status),
|
||||
"body": body_text,
|
||||
}),
|
||||
);
|
||||
return Err(format!("sidecar returned {}", status));
|
||||
}
|
||||
|
||||
// Read SSE stream chunk by chunk. SSE events are delimited by a blank
|
||||
// line (\n\n). Each block has zero or one `event:` line and one or more
|
||||
// `data:` lines. We accumulate bytes into a String buffer, then drain
|
||||
// complete blocks one at a time.
|
||||
let mut buffer = String::new();
|
||||
loop {
|
||||
match resp.chunk().await {
|
||||
Ok(Some(bytes)) => {
|
||||
if let Ok(s) = std::str::from_utf8(&bytes) {
|
||||
buffer.push_str(s);
|
||||
}
|
||||
while let Some(idx) = buffer.find("\n\n") {
|
||||
let block: String = buffer.drain(..idx + 2).collect();
|
||||
if let Some(parsed) = parse_sse_event(&block) {
|
||||
let _ = app.emit(&event_name, parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
let _ = app.emit(
|
||||
&end_event,
|
||||
json!({ "error": format!("stream read error: {}", e) }),
|
||||
);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drain any tail block that didn't end with a blank line (server may close
|
||||
// the connection without a final separator).
|
||||
let tail = buffer.trim();
|
||||
if !tail.is_empty() {
|
||||
if let Some(parsed) = parse_sse_event(tail) {
|
||||
let _ = app.emit(&event_name, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = app.emit(&end_event, json!({ "ok": true }));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse a single SSE event block. Returns `{ event, data }` where `data` is the
|
||||
/// parsed JSON value when possible, otherwise the raw concatenated data text.
|
||||
/// SSE allows multiple `data:` lines within a single event — they are joined
|
||||
/// with `\n` per the spec.
|
||||
fn parse_sse_event(block: &str) -> Option<Value> {
|
||||
let mut event_name = String::from("message");
|
||||
let mut data_lines: Vec<String> = Vec::new();
|
||||
|
||||
for line in block.lines() {
|
||||
if let Some(rest) = line.strip_prefix("event:") {
|
||||
event_name = rest.trim().to_string();
|
||||
} else if let Some(rest) = line.strip_prefix("data:") {
|
||||
// Per SSE spec, a single leading space after `data:` is stripped.
|
||||
let value = rest.strip_prefix(' ').unwrap_or(rest);
|
||||
data_lines.push(value.to_string());
|
||||
}
|
||||
// `id:` and `retry:` and comment (`:`) lines are intentionally ignored.
|
||||
}
|
||||
|
||||
if data_lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let data_str = data_lines.join("\n");
|
||||
let data_value: Value =
|
||||
serde_json::from_str(&data_str).unwrap_or_else(|_| Value::String(data_str));
|
||||
|
||||
Some(json!({
|
||||
"event": event_name,
|
||||
"data": data_value,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_sse_simple_data_only() {
|
||||
let block = "data: hello\n\n";
|
||||
let parsed = parse_sse_event(block).expect("should parse");
|
||||
assert_eq!(parsed["event"], "message");
|
||||
assert_eq!(parsed["data"], "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sse_event_and_json_data() {
|
||||
let block = "event: step\ndata: {\"chunk\":\"hi\"}\n\n";
|
||||
let parsed = parse_sse_event(block).expect("should parse");
|
||||
assert_eq!(parsed["event"], "step");
|
||||
assert_eq!(parsed["data"]["chunk"], "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sse_multiline_data_joined_with_newline() {
|
||||
let block = "event: step\ndata: line1\ndata: line2\n\n";
|
||||
let parsed = parse_sse_event(block).expect("should parse");
|
||||
assert_eq!(parsed["data"], "line1\nline2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sse_empty_data_returns_none() {
|
||||
let block = "event: ping\n\n";
|
||||
assert!(parse_sse_event(block).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sse_strips_single_leading_space_after_colon() {
|
||||
let block = "data: foo\n\n";
|
||||
let parsed = parse_sse_event(block).expect("should parse");
|
||||
assert_eq!(parsed["data"], "foo");
|
||||
}
|
||||
}
|
||||
43
app/src-tauri/src/commands/http.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
// CC Sesija A §2.1 Task A4 — shared HTTP helpers for sidecar-proxy commands.
|
||||
//
|
||||
// Extracted from commands/memory.rs at A4 because wiki.rs becomes the second
|
||||
// consumer (rule of two). Future command modules (onboarding A10+, agent.rs
|
||||
// where applicable) should also import from here rather than reimplementing.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
const SIDECAR_HOST: &str = "127.0.0.1";
|
||||
|
||||
pub fn sidecar_url(port: u16, path: &str) -> String {
|
||||
format!("http://{}:{}{}", SIDECAR_HOST, port, path)
|
||||
}
|
||||
|
||||
pub async fn http_get(url: &str) -> Result<reqwest::Response, String> {
|
||||
reqwest::get(url)
|
||||
.await
|
||||
.map_err(|e| format!("HTTP GET {} failed: {}", url, e))
|
||||
}
|
||||
|
||||
pub async fn http_post(url: &str, body: &Value) -> Result<reqwest::Response, String> {
|
||||
let client = reqwest::Client::new();
|
||||
client
|
||||
.post(url)
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("HTTP POST {} failed: {}", url, e))
|
||||
}
|
||||
|
||||
pub async fn parse_json(resp: reqwest::Response) -> Result<Value, String> {
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body_text = resp
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "<unreadable body>".to_string());
|
||||
return Err(format!("sidecar returned {}: {}", status, body_text));
|
||||
}
|
||||
resp.json::<Value>()
|
||||
.await
|
||||
.map_err(|e| format!("sidecar returned non-JSON body: {}", e))
|
||||
}
|
||||
137
app/src-tauri/src/commands/memory.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
// CC Sesija A §2.1 Tasks A1 + A4 — memory + KG + identity Tauri commands.
|
||||
//
|
||||
// Architecture: thin HTTP proxies to local sidecar (Fastify, port from ServiceState).
|
||||
// Tauri Rust shell intentionally has no direct sqlite/sqlite-vec dependencies — all
|
||||
// substrate access goes through the sidecar so that the storage layer (FrameStore,
|
||||
// HybridSearch, KnowledgeGraph, wiki-compiler) lives in one process.
|
||||
//
|
||||
// Command surface (4 commands here; wiki commands moved to commands/wiki.rs at A4):
|
||||
// recall_memory → GET /api/memory/search
|
||||
// save_memory → POST /api/memory/frames
|
||||
// search_entities → GET /api/memory/graph
|
||||
// get_identity → GET /api/identity (route not yet in sidecar — graceful 404
|
||||
// → placeholder; A1.1 follow-up adds route)
|
||||
//
|
||||
// Shared HTTP helpers extracted to commands/http.rs at A4 (rule of two — wiki.rs is
|
||||
// the second consumer).
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::http::{http_get, http_post, parse_json, sidecar_url};
|
||||
use crate::service::ServiceState;
|
||||
|
||||
/// Recall memory frames matching `query`. Optional `scope` (all|personal|workspace),
|
||||
/// `limit` (default sidecar-side ~20), `workspace_id` (workspace mind to search).
|
||||
#[tauri::command]
|
||||
pub async fn recall_memory(
|
||||
state: State<'_, ServiceState>,
|
||||
query: String,
|
||||
scope: Option<String>,
|
||||
limit: Option<u32>,
|
||||
workspace_id: Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
let mut url = format!(
|
||||
"{}?q={}",
|
||||
sidecar_url(state.port, "/api/memory/search"),
|
||||
urlencoding::encode(&query)
|
||||
);
|
||||
if let Some(s) = scope {
|
||||
url.push_str(&format!("&scope={}", urlencoding::encode(&s)));
|
||||
}
|
||||
if let Some(l) = limit {
|
||||
url.push_str(&format!("&limit={}", l));
|
||||
}
|
||||
if let Some(ws) = workspace_id {
|
||||
url.push_str(&format!("&workspace={}", urlencoding::encode(&ws)));
|
||||
}
|
||||
|
||||
let resp = http_get(&url).await?;
|
||||
parse_json(resp).await
|
||||
}
|
||||
|
||||
/// Persist a new memory frame. `content` is required. Optional `workspace_id`,
|
||||
/// `importance` (low|normal|high|critical per @waggle/core Importance enum), `source`
|
||||
/// (one of: user_stated, tool_verified, agent_inferred, import, system).
|
||||
#[tauri::command]
|
||||
pub async fn save_memory(
|
||||
state: State<'_, ServiceState>,
|
||||
content: String,
|
||||
workspace_id: Option<String>,
|
||||
importance: Option<String>,
|
||||
source: Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
let mut body = json!({ "content": content });
|
||||
if let Some(ws) = workspace_id {
|
||||
body["workspace"] = json!(ws);
|
||||
}
|
||||
if let Some(imp) = importance {
|
||||
body["importance"] = json!(imp);
|
||||
}
|
||||
if let Some(src) = source {
|
||||
body["source"] = json!(src);
|
||||
}
|
||||
|
||||
let url = sidecar_url(state.port, "/api/memory/frames");
|
||||
let resp = http_post(&url, &body).await?;
|
||||
parse_json(resp).await
|
||||
}
|
||||
|
||||
/// Search the knowledge graph for entities/relations. Returns `{nodes, edges}` shape
|
||||
/// from sidecar's KnowledgeGraph layer. `scope` defaults to "all" if not provided.
|
||||
#[tauri::command]
|
||||
pub async fn search_entities(
|
||||
state: State<'_, ServiceState>,
|
||||
workspace_id: Option<String>,
|
||||
scope: Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
let mut url = sidecar_url(state.port, "/api/memory/graph").to_string();
|
||||
let mut params: Vec<String> = Vec::new();
|
||||
if let Some(ws) = workspace_id {
|
||||
params.push(format!("workspace={}", urlencoding::encode(&ws)));
|
||||
}
|
||||
if let Some(s) = scope {
|
||||
params.push(format!("scope={}", urlencoding::encode(&s)));
|
||||
}
|
||||
if !params.is_empty() {
|
||||
url.push('?');
|
||||
url.push_str(¶ms.join("&"));
|
||||
}
|
||||
|
||||
let resp = http_get(&url).await?;
|
||||
parse_json(resp).await
|
||||
}
|
||||
|
||||
/// Get the user identity record from sidecar's IdentityLayer.
|
||||
///
|
||||
/// Backed by /api/identity sidecar route (A1.1 shipped 2026-04-30). The route
|
||||
/// always returns a 200 with either configured: true + IdentityLayer fields,
|
||||
/// or configured: false + null fields + optional _note. The 404 + transport-
|
||||
/// error fallbacks here remain as defense-in-depth — they were the original
|
||||
/// pre-A1.1 placeholders and now only fire on hard sidecar outages.
|
||||
#[tauri::command]
|
||||
pub async fn get_identity(state: State<'_, ServiceState>) -> Result<Value, String> {
|
||||
let url = sidecar_url(state.port, "/api/identity");
|
||||
match http_get(&url).await {
|
||||
Ok(resp) if resp.status().as_u16() == 404 => Ok(identity_placeholder(
|
||||
"sidecar route 404 (unexpected post-A1.1)",
|
||||
)),
|
||||
Ok(resp) => parse_json(resp).await,
|
||||
Err(_) => Ok(identity_placeholder("sidecar unreachable")),
|
||||
}
|
||||
}
|
||||
|
||||
fn identity_placeholder(note: &str) -> Value {
|
||||
json!({
|
||||
"configured": false,
|
||||
"name": null,
|
||||
"role": null,
|
||||
"department": null,
|
||||
"personality": null,
|
||||
"capabilities": null,
|
||||
"system_prompt": null,
|
||||
"created_at": null,
|
||||
"updated_at": null,
|
||||
"_note": note
|
||||
})
|
||||
}
|
||||
8
app/src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
// CC Sesija A §2.1 + §2.3: command modules grouped under commands/.
|
||||
// Each submodule exposes #[tauri::command] async fns wired in lib.rs invoke_handler.
|
||||
|
||||
pub mod agent;
|
||||
pub mod http;
|
||||
pub mod memory;
|
||||
pub mod onboarding;
|
||||
pub mod wiki;
|
||||
120
app/src-tauri/src/commands/onboarding.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
// CC Sesija A §2.3 Task A10 — first-launch detection via filesystem flag.
|
||||
//
|
||||
// Brief: briefs/2026-04-30-cc-sesija-A-waggle-apps-web-integration.md §2.3 Task A10
|
||||
//
|
||||
// Persists a flag file at `~/.waggle/first-launch.flag` so the onboarding wizard
|
||||
// state survives across app reinstalls (browser localStorage doesn't, since
|
||||
// Tauri builds may use a fresh WebView profile per install). The web `npm run
|
||||
// dev` path continues to use localStorage via useOnboarding — these commands
|
||||
// are the durable Tauri-mode addition, not a replacement.
|
||||
//
|
||||
// Cross-platform user-home resolution uses std::env (USERPROFILE on Windows,
|
||||
// HOME on Unix) to avoid pulling in a new dirs/home crate dep.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
const FLAG_DIR: &str = ".waggle";
|
||||
const FLAG_FILE: &str = "first-launch.flag";
|
||||
|
||||
fn home_dir() -> Option<PathBuf> {
|
||||
std::env::var_os("USERPROFILE")
|
||||
.or_else(|| std::env::var_os("HOME"))
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// Resolve the flag location: `WAGGLE_DATA_DIR` (when set and non-empty) else
|
||||
/// `~/.waggle` — the SAME resolution order the sidecar's resolveDataDir uses
|
||||
/// (UX-Refactor P4/D11). Without this, a custom-data-dir install reads/writes
|
||||
/// the flag in `~/.waggle` while the server's completion stamp lives in the
|
||||
/// data dir — and a stale `~/.waggle` flag from a prior default install would
|
||||
/// auto-skip onboarding against a brand-new data dir (P4 review, 5 findings).
|
||||
fn flag_path() -> Result<PathBuf, String> {
|
||||
if let Some(dir) = std::env::var_os("WAGGLE_DATA_DIR") {
|
||||
if !dir.is_empty() {
|
||||
return Ok(PathBuf::from(dir).join(FLAG_FILE));
|
||||
}
|
||||
}
|
||||
let home = home_dir().ok_or_else(|| {
|
||||
"could not resolve user home directory (USERPROFILE/HOME unset)".to_string()
|
||||
})?;
|
||||
Ok(home.join(FLAG_DIR).join(FLAG_FILE))
|
||||
}
|
||||
|
||||
/// Returns true if the user has not yet completed onboarding.
|
||||
/// Implementation: returns `!flag_file_exists`. On any IO error (e.g. home dir
|
||||
/// unresolvable in a sandboxed environment) returns `true` so the wizard runs
|
||||
/// — better to show the wizard once too often than to silently skip it.
|
||||
#[tauri::command]
|
||||
pub async fn is_first_launch() -> Result<bool, String> {
|
||||
let path = match flag_path() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Ok(true),
|
||||
};
|
||||
Ok(!path.exists())
|
||||
}
|
||||
|
||||
/// Marks onboarding as complete by creating the flag file. Idempotent.
|
||||
/// Creates the parent `~/.waggle/` directory if needed.
|
||||
#[tauri::command]
|
||||
pub async fn mark_first_launch_complete() -> Result<(), String> {
|
||||
let path = flag_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("create_dir_all {} failed: {}", parent.display(), e))?;
|
||||
}
|
||||
std::fs::write(&path, b"completed\n")
|
||||
.map_err(|e| format!("write {} failed: {}", path.display(), e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resets the first-launch flag (deletes the file). For dev / QA flows that
|
||||
/// need to re-trigger onboarding without a full reinstall.
|
||||
#[tauri::command]
|
||||
pub async fn reset_first_launch() -> Result<(), String> {
|
||||
let path = flag_path()?;
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path)
|
||||
.map_err(|e| format!("remove {} failed: {}", path.display(), e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn flag_path_resolution_order() {
|
||||
// ONE sequential test — env vars are process-global and cargo runs
|
||||
// tests in parallel threads; split tests would race on WAGGLE_DATA_DIR.
|
||||
let tmp = std::env::temp_dir().join(format!("waggle-test-home-{}", std::process::id()));
|
||||
std::env::set_var("USERPROFILE", &tmp);
|
||||
std::env::set_var("HOME", &tmp);
|
||||
|
||||
// Default: ~/.waggle.
|
||||
std::env::remove_var("WAGGLE_DATA_DIR");
|
||||
let path = flag_path().expect("flag_path resolves with USERPROFILE/HOME set");
|
||||
assert!(
|
||||
path.ends_with(".waggle/first-launch.flag")
|
||||
|| path.ends_with(".waggle\\first-launch.flag")
|
||||
);
|
||||
|
||||
// P4/D11: a custom data dir keeps the flag NEXT TO the server's stamp.
|
||||
let data_dir =
|
||||
std::env::temp_dir().join(format!("waggle-test-datadir-{}", std::process::id()));
|
||||
std::env::set_var("WAGGLE_DATA_DIR", &data_dir);
|
||||
let custom = flag_path().expect("flag_path resolves with WAGGLE_DATA_DIR set");
|
||||
assert!(custom.starts_with(&data_dir));
|
||||
assert!(custom.ends_with("first-launch.flag"));
|
||||
|
||||
// Empty env value falls through to the home default (matches resolveDataDir).
|
||||
std::env::set_var("WAGGLE_DATA_DIR", "");
|
||||
let fallback = flag_path().expect("flag_path resolves with empty WAGGLE_DATA_DIR");
|
||||
assert!(
|
||||
fallback.ends_with(".waggle/first-launch.flag")
|
||||
|| fallback.ends_with(".waggle\\first-launch.flag")
|
||||
);
|
||||
|
||||
std::env::remove_var("WAGGLE_DATA_DIR");
|
||||
}
|
||||
}
|
||||
75
app/src-tauri/src/commands/wiki.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
// CC Sesija A §2.1 Task A4 — wiki Tauri commands.
|
||||
//
|
||||
// Brief: briefs/2026-04-30-cc-sesija-A-waggle-apps-web-integration.md §2.1 Task A4
|
||||
//
|
||||
// Read-side wiki commands proxying to the sidecar's /api/wiki/* routes which read
|
||||
// from the per-workspace MindDB (FrameStore + KnowledgeGraph). The compile route
|
||||
// triggers a fresh wiki-compiler pass for the active workspace.
|
||||
//
|
||||
// Brief §2.1 Task A4 says "reads packages/wiki-compiler output direktno iz hive-mind
|
||||
// frame store" — direktno here means "without intermediate cache" not "direct
|
||||
// sqlite from Rust" (Tauri shell intentionally has no sqlite deps; substrate stays
|
||||
// in the sidecar process — see commands/memory.rs header for full rationale).
|
||||
//
|
||||
// Note: compile_wiki_section was previously colocated in commands/memory.rs (A1).
|
||||
// A4 moves it here for cohesion. Tauri command surface is unchanged; lib.rs
|
||||
// invoke_handler entry just rewires from `memory::compile_wiki_section` to
|
||||
// `wiki::compile_wiki_section`.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::http::{http_get, http_post, parse_json, sidecar_url};
|
||||
use crate::service::ServiceState;
|
||||
|
||||
/// List all compiled wiki pages for the active workspace. Returns the page
|
||||
/// index (slugs + titles + metadata); call get_wiki_page_content for the body.
|
||||
#[tauri::command]
|
||||
pub async fn get_wiki_pages(state: State<'_, ServiceState>) -> Result<Value, String> {
|
||||
let url = sidecar_url(state.port, "/api/wiki/pages");
|
||||
let resp = http_get(&url).await?;
|
||||
parse_json(resp).await
|
||||
}
|
||||
|
||||
/// Fetch a single wiki page's metadata (title, type, source frame ids, etc.)
|
||||
/// without the full markdown body. Use get_wiki_page_content for the body.
|
||||
#[tauri::command]
|
||||
pub async fn get_wiki_page(state: State<'_, ServiceState>, slug: String) -> Result<Value, String> {
|
||||
let url = sidecar_url(
|
||||
state.port,
|
||||
&format!("/api/wiki/pages/{}", urlencoding::encode(&slug)),
|
||||
);
|
||||
let resp = http_get(&url).await?;
|
||||
parse_json(resp).await
|
||||
}
|
||||
|
||||
/// Fetch the full markdown content for a wiki page by slug. Returns whatever
|
||||
/// the sidecar emits (typically `{ slug, content, ... }`).
|
||||
#[tauri::command]
|
||||
pub async fn get_wiki_page_content(
|
||||
state: State<'_, ServiceState>,
|
||||
slug: String,
|
||||
) -> Result<Value, String> {
|
||||
let url = sidecar_url(
|
||||
state.port,
|
||||
&format!("/api/wiki/pages/{}/content", urlencoding::encode(&slug)),
|
||||
);
|
||||
let resp = http_get(&url).await?;
|
||||
parse_json(resp).await
|
||||
}
|
||||
|
||||
/// Trigger wiki compilation for the active or specified workspace. Returns the
|
||||
/// compilation summary (page count, entities, gaps) from packages/wiki-compiler.
|
||||
#[tauri::command]
|
||||
pub async fn compile_wiki_section(
|
||||
state: State<'_, ServiceState>,
|
||||
workspace_id: Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
let mut body = json!({});
|
||||
if let Some(ws) = workspace_id {
|
||||
body["workspace"] = json!(ws);
|
||||
}
|
||||
let url = sidecar_url(state.port, "/api/wiki/compile");
|
||||
let resp = http_post(&url, &body).await?;
|
||||
parse_json(resp).await
|
||||
}
|
||||
130
app/src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
// Tauri 2 requires a lib.rs for the cdylib/staticlib crate types.
|
||||
// The actual app entry point is main.rs.
|
||||
|
||||
mod commands;
|
||||
mod service;
|
||||
mod tray;
|
||||
|
||||
use service::ServiceState;
|
||||
use tauri::Manager;
|
||||
|
||||
#[tauri::command]
|
||||
async fn show_notification(
|
||||
app: tauri::AppHandle,
|
||||
title: String,
|
||||
body: String,
|
||||
) -> Result<(), String> {
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
app.notification()
|
||||
.builder()
|
||||
.title(&title)
|
||||
.body(&body)
|
||||
.show()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_autostart::init(
|
||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||
Some(vec!["--minimized"]),
|
||||
))
|
||||
.manage(ServiceState::new(3333))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
service::ensure_service,
|
||||
service::stop_service,
|
||||
service::get_service_port,
|
||||
show_notification,
|
||||
commands::memory::recall_memory,
|
||||
commands::memory::save_memory,
|
||||
commands::memory::search_entities,
|
||||
commands::memory::get_identity,
|
||||
commands::wiki::get_wiki_pages,
|
||||
commands::wiki::get_wiki_page,
|
||||
commands::wiki::get_wiki_page_content,
|
||||
commands::wiki::compile_wiki_section,
|
||||
commands::agent::run_agent_query,
|
||||
commands::onboarding::is_first_launch,
|
||||
commands::onboarding::mark_first_launch_complete,
|
||||
commands::onboarding::reset_first_launch,
|
||||
])
|
||||
.setup(|app| {
|
||||
tray::setup_tray(app.handle())?;
|
||||
|
||||
// Register global hotkey: Ctrl+Shift+W to toggle window visibility
|
||||
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut};
|
||||
let app_handle = app.handle().clone();
|
||||
app.handle().plugin(
|
||||
tauri_plugin_global_shortcut::Builder::new()
|
||||
.with_handler(move |_app, _shortcut, event| {
|
||||
if event.state == tauri_plugin_global_shortcut::ShortcutState::Pressed {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
if window.is_visible().unwrap_or(false) {
|
||||
let _ = window.hide();
|
||||
} else {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(),
|
||||
)?;
|
||||
|
||||
let shortcut = Shortcut::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyW);
|
||||
// R7-005: a hotkey collision must not crash setup — log and continue.
|
||||
if let Err(e) = app.global_shortcut().register(shortcut) {
|
||||
eprintln!(
|
||||
"[waggle] Failed to register Ctrl+Shift+W global shortcut: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-start the sidecar service before the webview loads so the
|
||||
// React app finds it already healthy on localhost:3333.
|
||||
let service_state = app.state::<ServiceState>();
|
||||
let port = service_state.port;
|
||||
match service::spawn_service_sync(port, &service_state.process) {
|
||||
Ok(()) => eprintln!("[waggle] Sidecar spawn initiated on port {}", port),
|
||||
Err(e) => eprintln!("[waggle] Failed to auto-start sidecar: {}", e),
|
||||
}
|
||||
|
||||
// Start service watchdog
|
||||
let app_handle_watchdog = app.handle().clone();
|
||||
service::start_watchdog(app_handle_watchdog, port);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
// Window management: close minimizes to tray instead of quitting
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| {
|
||||
// R7-002: kill the sidecar on app exit so it doesn't orphan and hold port 3333.
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
if let Some(state) = app_handle.try_state::<ServiceState>() {
|
||||
if let Ok(mut proc) = state.process.lock() {
|
||||
if let Some(mut child) = proc.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
6
app/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
waggle_lib::run();
|
||||
}
|
||||
361
app/src-tauri/src/service.rs
Normal file
@@ -0,0 +1,361 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
|
||||
pub struct ServiceState {
|
||||
pub process: Mutex<Option<Child>>,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl ServiceState {
|
||||
pub fn new(port: u16) -> Self {
|
||||
Self {
|
||||
process: Mutex::new(None),
|
||||
port,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the Node.js binary path.
|
||||
/// Priority: WAGGLE_NODE_PATH env → bundled resources/node[.exe] → system PATH "node"
|
||||
fn resolve_node_path() -> String {
|
||||
// 1. Explicit env override (development/advanced users)
|
||||
if let Ok(custom) = std::env::var("WAGGLE_NODE_PATH") {
|
||||
return custom;
|
||||
}
|
||||
|
||||
// 2. Bundled Node.js in resources/ directory (next to exe)
|
||||
let exe_dir = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.to_path_buf()));
|
||||
|
||||
if let Some(ref dir) = exe_dir {
|
||||
let bundled = if cfg!(windows) {
|
||||
dir.join("resources").join("node.exe")
|
||||
} else {
|
||||
dir.join("resources").join("node")
|
||||
};
|
||||
if bundled.exists() {
|
||||
return bundled.to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Development fallback: relative to src-tauri working dir
|
||||
if cfg!(debug_assertions) {
|
||||
let dev_resources = if cfg!(windows) {
|
||||
"resources/node.exe"
|
||||
} else {
|
||||
"resources/node"
|
||||
};
|
||||
if std::path::Path::new(dev_resources).exists() {
|
||||
return dev_resources.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// 4. System PATH fallback
|
||||
"node".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum ServiceScriptKind {
|
||||
Bundled,
|
||||
DevSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct ServiceScript {
|
||||
path: PathBuf,
|
||||
kind: ServiceScriptKind,
|
||||
}
|
||||
|
||||
fn find_dev_service_script(current_dir: &Path) -> Option<PathBuf> {
|
||||
for dir in current_dir.ancestors() {
|
||||
let candidate = dir
|
||||
.join("packages")
|
||||
.join("server")
|
||||
.join("src")
|
||||
.join("local")
|
||||
.join("service.ts");
|
||||
if candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve_service_script(
|
||||
exe_dir: Option<&Path>,
|
||||
current_dir: &Path,
|
||||
) -> Result<ServiceScript, String> {
|
||||
if let Some(dir) = exe_dir {
|
||||
let bundled = dir.join("resources").join("service.js");
|
||||
if bundled.exists() {
|
||||
return Ok(ServiceScript {
|
||||
path: bundled,
|
||||
kind: ServiceScriptKind::Bundled,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if cfg!(debug_assertions) {
|
||||
if let Some(script) = find_dev_service_script(current_dir) {
|
||||
return Ok(ServiceScript {
|
||||
path: script,
|
||||
kind: ServiceScriptKind::DevSource,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Err("Unable to locate sidecar service.js resource".to_string())
|
||||
}
|
||||
|
||||
/// Build the Command used to spawn the sidecar process.
|
||||
/// Shared by both the sync auto-start path and the async `ensure_service` tauri command.
|
||||
fn build_service_command(port: u16) -> Result<Command, String> {
|
||||
let node_path = resolve_node_path();
|
||||
|
||||
let exe_dir = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.to_path_buf()));
|
||||
|
||||
let current_dir = std::env::current_dir().map_err(|e| e.to_string())?;
|
||||
let service_script = resolve_service_script(exe_dir.as_deref(), ¤t_dir)?;
|
||||
|
||||
let mut cmd = if service_script.kind == ServiceScriptKind::DevSource {
|
||||
let mut c = Command::new(&node_path);
|
||||
c.arg("--import").arg("tsx").arg(&service_script.path);
|
||||
c
|
||||
} else {
|
||||
let mut c = Command::new(&node_path);
|
||||
c.arg(&service_script.path);
|
||||
c
|
||||
};
|
||||
|
||||
cmd.env("WAGGLE_PORT", port.to_string());
|
||||
|
||||
if service_script.kind == ServiceScriptKind::Bundled {
|
||||
cmd.env("WAGGLE_SKIP_LITELLM", "1");
|
||||
|
||||
if let Some(ref dir) = exe_dir {
|
||||
let resources_dir = dir.join("resources");
|
||||
let native_dir = resources_dir.join("native");
|
||||
let node_modules_dir = resources_dir.join("node_modules");
|
||||
|
||||
// NODE_PATH must include the staged production deps
|
||||
// (resources/node_modules — better-sqlite3, @fastify/static,
|
||||
// drizzle-orm, @huggingface/transformers, …) so the sidecar's bare
|
||||
// require()/import() calls resolve, plus resources/native for any
|
||||
// abs-path native consumers. Node accepts multiple entries,
|
||||
// ';'-separated on Windows and ':' elsewhere.
|
||||
let sep = if cfg!(windows) { ";" } else { ":" };
|
||||
let node_path = format!(
|
||||
"{}{}{}",
|
||||
node_modules_dir.to_string_lossy(),
|
||||
sep,
|
||||
native_dir.to_string_lossy(),
|
||||
);
|
||||
cmd.env("NODE_PATH", node_path);
|
||||
|
||||
let vec_ext = if cfg!(windows) {
|
||||
native_dir.join("vec0.dll")
|
||||
} else if cfg!(target_os = "macos") {
|
||||
native_dir.join("vec0.dylib")
|
||||
} else {
|
||||
native_dir.join("vec0.so")
|
||||
};
|
||||
if vec_ext.exists() {
|
||||
cmd.env("WAGGLE_SQLITE_VEC_PATH", vec_ext.to_string_lossy().as_ref());
|
||||
}
|
||||
|
||||
let ort_dir = native_dir.join("onnxruntime");
|
||||
if ort_dir.exists() {
|
||||
cmd.env(
|
||||
"ONNXRUNTIME_NODE_BINDING_PATH",
|
||||
ort_dir.to_string_lossy().as_ref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn service_script_prefers_bundled_resource_when_present() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("waggle-service-script-{}", std::process::id()));
|
||||
let resources = root.join("resources");
|
||||
std::fs::create_dir_all(&resources).expect("creates temp resources");
|
||||
std::fs::write(resources.join("service.js"), "console.log('ok')").expect("writes service");
|
||||
|
||||
let script = resolve_service_script(Some(&root), Path::new("D:/Projects/waggle-os"))
|
||||
.expect("bundled script resolves");
|
||||
|
||||
assert_eq!(script.kind, ServiceScriptKind::Bundled);
|
||||
assert_eq!(script.path, resources.join("service.js"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dev_service_script_searches_current_dir_ancestors() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("waggle-dev-service-script-{}", std::process::id()));
|
||||
let script = root
|
||||
.join("packages")
|
||||
.join("server")
|
||||
.join("src")
|
||||
.join("local")
|
||||
.join("service.ts");
|
||||
std::fs::create_dir_all(script.parent().expect("script parent")).expect("creates dirs");
|
||||
std::fs::write(&script, "export {};").expect("writes service");
|
||||
|
||||
let nested = root.join("app").join("src-tauri");
|
||||
std::fs::create_dir_all(&nested).expect("creates nested cwd");
|
||||
|
||||
assert_eq!(find_dev_service_script(&nested), Some(script));
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronously spawn the sidecar process if not already running. Does not wait
|
||||
/// for the health check. Safe to call from Tauri's synchronous `.setup()` callback.
|
||||
pub fn spawn_service_sync(port: u16, process: &Mutex<Option<Child>>) -> Result<(), String> {
|
||||
{
|
||||
let proc = process.lock().map_err(|e| e.to_string())?;
|
||||
if proc.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let mut cmd = build_service_command(port)?;
|
||||
let child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start service: {}", e))?;
|
||||
|
||||
let mut proc = process.lock().map_err(|e| e.to_string())?;
|
||||
*proc = Some(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ensure_service(state: State<'_, ServiceState>) -> Result<String, String> {
|
||||
let port = state.port;
|
||||
let health_url = format!("http://127.0.0.1:{}/health", port);
|
||||
|
||||
match reqwest::get(&health_url).await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
return Ok("Service already running".to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
spawn_service_sync(port, &state.process)?;
|
||||
|
||||
for _ in 0..30 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
match reqwest::get(&health_url).await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
return Ok("Service started".to_string());
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
Err("Service failed to start within 30 seconds".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_service(state: State<'_, ServiceState>) -> Result<String, String> {
|
||||
let mut proc = state.process.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(mut child) = proc.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
Ok("Service stopped".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_service_port(state: State<'_, ServiceState>) -> Result<u16, String> {
|
||||
Ok(state.port)
|
||||
}
|
||||
|
||||
pub fn start_watchdog(app: AppHandle, port: u16) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let health_url = format!("http://127.0.0.1:{}/health", port);
|
||||
let mut consecutive_failures: u32 = 0;
|
||||
let mut restart_count: u32 = 0;
|
||||
let mut restart_window_start = Instant::now();
|
||||
const MAX_RESTARTS: u32 = 5;
|
||||
const RESTART_WINDOW: Duration = Duration::from_secs(600);
|
||||
|
||||
// Wait for initial startup
|
||||
tokio::time::sleep(Duration::from_secs(15)).await;
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
|
||||
match reqwest::get(&health_url).await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
consecutive_failures = 0;
|
||||
}
|
||||
_ => {
|
||||
consecutive_failures += 1;
|
||||
if consecutive_failures >= 3 {
|
||||
if restart_window_start.elapsed() > RESTART_WINDOW {
|
||||
restart_count = 0;
|
||||
restart_window_start = Instant::now();
|
||||
}
|
||||
|
||||
if restart_count >= MAX_RESTARTS {
|
||||
let _ = app.emit(
|
||||
"waggle://service-status",
|
||||
serde_json::json!({ "status": "failed" }),
|
||||
);
|
||||
eprintln!("[waggle] Watchdog: max restarts exceeded, giving up");
|
||||
break;
|
||||
}
|
||||
|
||||
let _ = app.emit(
|
||||
"waggle://service-status",
|
||||
serde_json::json!({ "status": "restarting" }),
|
||||
);
|
||||
eprintln!(
|
||||
"[waggle] Watchdog: server unresponsive, respawning (attempt {})",
|
||||
restart_count + 1
|
||||
);
|
||||
|
||||
// R7-003: self-heal — reap the dead child (so spawn_service_sync's
|
||||
// is_some() early-return clears) then respawn the sidecar in place.
|
||||
if let Some(state) = app.try_state::<ServiceState>() {
|
||||
{
|
||||
if let Ok(mut proc) = state.process.lock() {
|
||||
if let Some(mut child) = proc.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
match spawn_service_sync(port, &state.process) {
|
||||
Ok(()) => eprintln!("[waggle] Watchdog: sidecar respawned"),
|
||||
Err(e) => eprintln!("[waggle] Watchdog: respawn failed: {}", e),
|
||||
}
|
||||
}
|
||||
let _ = app.emit("waggle://service-restart-needed", ());
|
||||
|
||||
restart_count += 1;
|
||||
consecutive_failures = 0;
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
76
app/src-tauri/src/tray.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use tauri::{
|
||||
image::Image,
|
||||
menu::{MenuBuilder, MenuItemBuilder},
|
||||
tray::TrayIconBuilder,
|
||||
AppHandle, Emitter, Manager,
|
||||
};
|
||||
|
||||
fn generate_tray_icon() -> (Vec<u8>, u32, u32) {
|
||||
let size: u32 = 32;
|
||||
let mut rgba = vec![0u8; (size * size * 4) as usize];
|
||||
// Orange filled square with rounded-ish corners
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let idx = ((y * size + x) * 4) as usize;
|
||||
// Simple circle mask for rounded look
|
||||
let cx = (x as f32) - 15.5;
|
||||
let cy = (y as f32) - 15.5;
|
||||
let dist = (cx * cx + cy * cy).sqrt();
|
||||
if dist < 14.0 {
|
||||
// Orange: #E8922A
|
||||
rgba[idx] = 0xE8; // R
|
||||
rgba[idx + 1] = 0x92; // G
|
||||
rgba[idx + 2] = 0x2A; // B
|
||||
rgba[idx + 3] = 0xFF; // A
|
||||
}
|
||||
}
|
||||
}
|
||||
(rgba, size, size)
|
||||
}
|
||||
|
||||
fn show_main_window(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn setup_tray(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let show = MenuItemBuilder::with_id("show", "Open Waggle").build(app)?;
|
||||
let settings = MenuItemBuilder::with_id("settings", "Settings").build(app)?;
|
||||
let quit = MenuItemBuilder::with_id("quit", "Quit Waggle").build(app)?;
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.items(&[&show, &settings, &quit])
|
||||
.build()?;
|
||||
|
||||
let (rgba, w, h) = generate_tray_icon();
|
||||
let icon = Image::new_owned(rgba, w, h);
|
||||
|
||||
TrayIconBuilder::new()
|
||||
.icon(icon)
|
||||
.menu(&menu)
|
||||
.tooltip("Waggle Agent Service")
|
||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||
"show" => {
|
||||
show_main_window(app);
|
||||
}
|
||||
"settings" => {
|
||||
show_main_window(app);
|
||||
let _ = app.emit("waggle://navigate", "/settings");
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let tauri::tray::TrayIconEvent::Click { .. } = event {
|
||||
show_main_window(tray.app_handle());
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
eprintln!("[waggle] System tray icon created successfully");
|
||||
Ok(())
|
||||
}
|
||||
10
app/src-tauri/tauri.build-override.conf.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"build": {
|
||||
"beforeBuildCommand": ""
|
||||
},
|
||||
"bundle": {
|
||||
"macOS": {
|
||||
"signingIdentity": "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
53
app/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Waggle",
|
||||
"version": "0.2.0",
|
||||
"identifier": "com.egzakta.waggle",
|
||||
"build": {
|
||||
"frontendDist": "../../apps/web/dist",
|
||||
"devUrl": "http://localhost:8080",
|
||||
"beforeDevCommand": "cd ../apps/web && npx vite",
|
||||
"beforeBuildCommand": "node ../scripts/build-sidecar.mjs && node ../scripts/check-sidecar-resources.mjs && cd ../apps/web && npx vite build"
|
||||
},
|
||||
"bundle": {
|
||||
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico", "icons/icon.png"],
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"resources": ["resources/*", "resources/native/*", "resources/native/onnxruntime/*", "resources/node_modules/**/*"],
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installerHooks": "nsis/installer.nsi",
|
||||
"installerIcon": "icons/icon.ico",
|
||||
"installMode": "currentUser",
|
||||
"languages": ["English"],
|
||||
"displayLanguageSelector": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Waggle",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https://us.i.posthog.com; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:"
|
||||
},
|
||||
"trayIcon": {
|
||||
"iconPath": "icons/icon.png",
|
||||
"tooltip": "Waggle - AI Agent Swarm"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
}
|
||||
}
|
||||
}
|
||||
10
app/src-tauri/tauri.dev-override.conf.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"build": {
|
||||
"beforeDevCommand": ""
|
||||
},
|
||||
"app": {
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https://us.i.posthog.com; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' data: https: blob:"
|
||||
}
|
||||
}
|
||||
}
|
||||