diff --git a/Cargo.toml b/Cargo.toml index aa062b4..90815c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ thiserror = "2" # Logging tracing = "0.1" +chrono = "0.4" # Async (optional, for client) tokio = { version = "1", features = ["rt", "net", "io-util", "time"], optional = true } diff --git a/logs/user_prompt_submit.json b/logs/user_prompt_submit.json new file mode 100644 index 0000000..a404f98 --- /dev/null +++ b/logs/user_prompt_submit.json @@ -0,0 +1,90 @@ +[ + { + "session_id": "10296741-0b3a-4bd0-95ec-07fc3a823bb1", + "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/10296741-0b3a-4bd0-95ec-07fc3a823bb1.jsonl", + "cwd": "/Users/wolfgangschoenberger/Projects/fgp/daemon", + "permission_mode": "bypassPermissions", + "hook_event_name": "UserPromptSubmit", + "prompt": "are all the FGP modules published to github? please publish" + }, + { + "session_id": "10296741-0b3a-4bd0-95ec-07fc3a823bb1", + "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/10296741-0b3a-4bd0-95ec-07fc3a823bb1.jsonl", + "cwd": "/Users/wolfgangschoenberger/Projects/fgp/daemon", + "permission_mode": "bypassPermissions", + "hook_event_name": "UserPromptSubmit", + "prompt": "commit the fly client.rs fix" + }, + { + "session_id": "57ed67ac-2bd7-4367-9402-225a5ad61c75", + "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/57ed67ac-2bd7-4367-9402-225a5ad61c75.jsonl", + "cwd": "/Users/wolfgangschoenberger/Projects/fgp/daemon", + "permission_mode": "bypassPermissions", + "hook_event_name": "UserPromptSubmit", + "prompt": "commit the protocol compliance tests" + }, + { + "session_id": "57ed67ac-2bd7-4367-9402-225a5ad61c75", + "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/57ed67ac-2bd7-4367-9402-225a5ad61c75.jsonl", + "cwd": "/Users/wolfgangschoenberger/Projects/fgp/daemon", + "permission_mode": "bypassPermissions", + "hook_event_name": "UserPromptSubmit", + "prompt": "push to github" + }, + { + "session_id": "57ed67ac-2bd7-4367-9402-225a5ad61c75", + "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/57ed67ac-2bd7-4367-9402-225a5ad61c75.jsonl", + "cwd": "/Users/wolfgangschoenberger/Projects/fgp/daemon", + "permission_mode": "bypassPermissions", + "hook_event_name": "UserPromptSubmit", + "prompt": "what's next on the fgp plan" + }, + { + "session_id": "57ed67ac-2bd7-4367-9402-225a5ad61c75", + "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/57ed67ac-2bd7-4367-9402-225a5ad61c75.jsonl", + "cwd": "/Users/wolfgangschoenberger/Projects/fgp/daemon", + "permission_mode": "bypassPermissions", + "hook_event_name": "UserPromptSubmit", + "prompt": "do the gmail attachments support" + }, + { + "session_id": "57ed67ac-2bd7-4367-9402-225a5ad61c75", + "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/57ed67ac-2bd7-4367-9402-225a5ad61c75.jsonl", + "cwd": "/Users/wolfgangschoenberger/Projects/fgp/daemon", + "permission_mode": "bypassPermissions", + "hook_event_name": "UserPromptSubmit", + "prompt": "do the gmail attachments support" + }, + { + "session_id": "57ed67ac-2bd7-4367-9402-225a5ad61c75", + "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/57ed67ac-2bd7-4367-9402-225a5ad61c75.jsonl", + "cwd": "/Users/wolfgangschoenberger/Projects/fgp/daemon", + "permission_mode": "bypassPermissions", + "hook_event_name": "UserPromptSubmit", + "prompt": "create PR for gmail attachment change and set a reminder for 10 minutes for me to check on it" + }, + { + "session_id": "b084daa3-6131-406b-a78c-8246feeb00fe", + "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/b084daa3-6131-406b-a78c-8246feeb00fe.jsonl", + "cwd": "/Users/wolfgangschoenberger/Projects/fgp/daemon", + "permission_mode": "bypassPermissions", + "hook_event_name": "UserPromptSubmit", + "prompt": "yes push to origin" + }, + { + "session_id": "b084daa3-6131-406b-a78c-8246feeb00fe", + "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/b084daa3-6131-406b-a78c-8246feeb00fe.jsonl", + "cwd": "/Users/wolfgangschoenberger/Projects/fgp/daemon", + "permission_mode": "plan", + "hook_event_name": "UserPromptSubmit", + "prompt": "what should we do next in terms of polishing the repos/org and adding documentation?" + }, + { + "session_id": "b084daa3-6131-406b-a78c-8246feeb00fe", + "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/b084daa3-6131-406b-a78c-8246feeb00fe.jsonl", + "cwd": "/Users/wolfgangschoenberger/Projects/fgp/daemon", + "permission_mode": "bypassPermissions", + "hook_event_name": "UserPromptSubmit", + "prompt": "\na080b89\ncompleted\nAgent \"Audit FGP repo documentation\" completed\nNow let me compile my findings into a comprehensive audit report. Based on my exploration, I have enough information to provide a detailed assessment.\n\n## FGP Documentation Audit Report\n\nI've completed a comprehensive audit of the FGP repository documentation. Here's the detailed analysis:\n\n---\n\n## Executive Summary\n\n**Overall Status:** Good foundation with significant gaps in service-specific documentation\n\n**Key Findings:**\n- Strong: Core README, protocol spec, contribution guidelines, browser daemon\n- Weak: 5 service daemons (Gmail, Calendar, GitHub, Fly, Neon, Vercel) lack depth\n- Missing: Troubleshooting guides, architecture diagrams, API method documentation, tests\n\n---\n\n## Repository-by-Repository Audit\n\n### Root Level (`/fgp/`)\n**Status:** Strong\n\n| Aspect | Assessment |\n|--------|-----------|\n| README.md | Excellent - 324 lines, comprehensive overview, performance tables, quick start for all services |\n| Architecture | Well-documented with ASCII diagram |\n| Installation | Clear per-service instructions |\n| Protocol Overview | Concise explanation of NDJSON format |\n| CLAUDE.md | Excellent - detailed working guidelines |\n| CHANGELOG.md | Present but minimal (1 entry) |\n| Contributing Guide | Good - covers dev setup, testing, commit style |\n| Status Table | Excellent - clear component status matrix |\n\n**Gaps:**\n- No troubleshooting/FAQ section\n- No architecture decision records (ADRs)\n- No integration examples (how to use multiple daemons together)\n\n---\n\n### Core SDKs\n\n#### `daemon/` (Rust SDK)\n**Status:** Strong\n\n| Aspect | Assessment |\n|--------|-----------|\n| README.md | 324 lines - Good overview, performance highlights |\n| Inline Docs | Excellent - service.rs has 50+ lines of detailed rustdoc examples |\n| Examples | 1 example (echo_daemon.rs) |\n| CHANGELOG.md | Present with detailed entries |\n| Code Structure | Well-organized (protocol.rs, server.rs, service.rs, client.rs, lifecycle.rs) |\n\n**Gaps:**\n- Only 1 example (should have: Python module, auth flow, multi-method service)\n- No API documentation comments on server.rs\n- No performance tuning guide\n- No error handling guide\n\n#### `daemon-py/` (Python SDK)\n**Status:** Good\n\n| Aspect | Assessment |\n|--------|-----------|\n| README.md | 164 lines - Comprehensive with code examples |\n| Examples | 1 example (echo_daemon.py) |\n| Inline Docs | Good docstrings but sparse |\n| API Stability | Unclear (no changelog) |\n\n**Gaps:**\n- No CHANGELOG.md\n- Only 1 example (needs: async patterns, error handling, integration examples)\n- No type stub documentation (.pyi files)\n- Missing migration guide from daemon-sdk to daemon-py\n\n#### `protocol/` (Protocol Spec)\n**Status:** Excellent\n\n| Aspect | Assessment |\n|--------|-----------|\n| FGP-PROTOCOL.md | Excellent - 80+ lines of formal specification |\n| Section Coverage | Complete (transport, framing, versioning, error handling) |\n| Examples | Multiple JSON request/response examples |\n| README.md | Basic reference |\n\n**Gaps:**\n- No versioning history (how to handle protocol evolution)\n- No backward compatibility guarantees\n- No performance benchmarks for protocol overhead\n\n#### `cli/` (FGP CLI)\n**Status:** Moderate\n\n| Aspect | Assessment |\n|--------|-----------|\n| README.md | 110 lines - Good command reference |\n| Commands Documented | Yes - table format |\n| Examples | 1 example (workflow.sh) |\n| CHANGELOG.md | Minimal entries |\n| Installation | Clear |\n\n**Gaps:**\n- No configuration file documentation (where does .fgp/config.json go?)\n- No troubleshooting (what if daemon won't start?)\n- No shell completion setup\n- Missing: daemon management (restart, logs, debug)\n\n---\n\n### Browser Daemon\n**Status:** Excellent\n\n| Aspect | Assessment |\n|--------|-----------|\n| README.md | 224 lines - Very comprehensive |\n| Quick Start | Detailed with 5 code examples |\n| CLI Commands | Complete table with all commands |\n| Architecture | Clear ASCII diagram |\n| Performance Tips | 4 specific tips |\n| Examples | 1 quickstart.rs example |\n| CHANGELOG.md | Present |\n| Inline Docs | Good (service.rs has clear structure comments) |\n\n**Gaps:**\n- No error handling guide (what if Chrome crashes?)\n- No session lifecycle documentation\n- No ARIA tree format specification\n- No CSS selector vs element ref (@e1) performance comparison\n\n---\n\n### Gmail Daemon\n**Status:** Moderate\n\n| Aspect | Assessment |\n|--------|-----------|\n| README.md | 178 lines - Basic coverage |\n| Installation | Covers automated + manual setup |\n| OAuth Setup | Clear steps (Google Console \u2192 credentials.json) |\n| Methods Documented | 6 methods with examples |\n| Performance | Comparison table present |\n| Response Format | 1 JSON example |\n| CHANGELOG.md | Present in main.rs but not separate file |\n\n**Gaps:**\n- **No examples directory** (needs: OAuth flow, batch operations, error handling, attachment handling)\n- **No inline documentation** in main.rs beyond top comment\n- No troubleshooting guide (OAuth failures, quota limits, authentication refresh)\n- No attachment handling details (size limits, types)\n- No search query syntax guide (only brief examples)\n- No threading/conversation handling guide\n- Missing: What happens to tokens? How long do they last?\n\n---\n\n### Calendar Daemon\n**Status:** Moderate\n\n| Aspect | Assessment |\n|--------|-----------|\n| README.md | 83 lines - Basic but incomplete |\n| Installation | Covered |\n| Methods | Table of 5 methods |\n| Setup | Clear |\n| Performance | Metrics present |\n\n**Gaps:**\n- **No examples directory**\n- **Extremely sparse** - only 83 lines for entire daemon\n- No free slot algorithm explanation\n- No timezone handling documentation\n- No calendar selection (primary vs other calendars)\n- No recurring event handling\n- No inline code documentation\n- Missing: oauth setup details (points to generic Google Calendar API docs)\n- No event creation schema (date format, time zones, attendees)\n\n---\n\n### GitHub Daemon\n**Status:** Moderate\n\n| Aspect | Assessment |\n|--------|-----------|\n| README.md | 151 lines - Good foundation |\n| Installation | Clear (requires gh CLI auth) |\n| Methods | 5 methods documented with params |\n| Quick Start | 6 examples |\n| Architecture | Flow diagram present |\n| Protocol | Example request/response |\n\n**Gaps:**\n- **No examples directory**\n- No gh CLI auth setup guide (assumes user already knows)\n- No GraphQL query examples (implementation detail)\n- No pagination handling\n- No rate limiting guidance\n- No error scenarios (e.g., private repos, no access)\n- Missing: notification filtering options\n- No PR status schema (what's returned?)\n- No repository filtering (forks, templates, archived)\n\n---\n\n### Fly.io Daemon\n**Status:** Moderate-Good\n\n| Aspect | Assessment |\n|--------|-----------|\n| README.md | 74 lines - Basic but organized |\n| Installation | Clear with Rust version requirement |\n| Quick Start | 4 examples |\n| Methods | 4 methods with param details |\n| Performance | Comparison vs MCP |\n| Use Cases | Listed (good for context) |\n\n**Gaps:**\n- **No examples directory**\n- No FLY_API_TOKEN creation guide (assumes knowledge)\n- No machine status constants (what values possible?)\n- No deployment history access\n- No environment variable handling\n- No error handling (what if token is invalid?)\n- Missing: volume/database management methods\n\n---\n\n### Neon Daemon\n**Status:** Moderate-Good\n\n| Aspect | Assessment |\n|--------|-----------|\n| README.md | 87 lines - More complete than Fly |\n| Installation | Clear requirements |\n| Quick Start | 4 examples with credentials setup |\n| Methods | 8 methods (most comprehensive of services) |\n| Use Cases | Listed (AI agents, schema exploration, data validation) |\n| SQL Support | Specific example included |\n\n**Gaps:**\n- **No examples directory**\n- No API key creation guide (step-by-step from Neon console)\n- No SQL error handling (what if query fails?)\n- No transaction support documented\n- No branch strategy documentation\n- No connection pooling documentation\n- Missing: table size limits, query timeouts\n- No backup/restore methods\n\n---\n\n### Vercel Daemon\n**Status:** Moderate-Good\n\n| Aspect | Assessment |\n|--------|-----------|\n| README.md | 86 lines - Well-structured |\n| Installation | Clear |\n| Quick Start | 4 examples |\n| Methods | 6 methods documented |\n| Use Cases | Listed (deployment monitoring, log debugging) |\n| Performance | Comparison table |\n\n**Gaps:**\n- **No examples directory**\n- No VERCEL_TOKEN creation guide\n- No log format specification\n- No deployment status constants (what states?)\n- No environment/secret management\n- No preview deployment handling\n- Missing: project domain management\n\n---\n\n### MCP Bridge\n**Status:** Good\n\n| Aspect | Assessment |\n|--------|========|\n| README.md | 92 lines - Clear purpose |\n| Architecture | Good flow diagram |\n| Tool Discovery | Explained well |\n| Configuration | Claude Desktop example provided |\n| Testing | Manual test examples |\n| Use Cases | Explained clearly |\n\n**Gaps:**\n- No examples directory\n- No error handling (what if daemon crashes while bridge runs?)\n- No performance overhead analysis\n- No fallback behavior documentation\n- No JSON Schema generation details\n- Missing: compatibility matrix (which MCP clients tested?)\n\n---\n\n## Documentation Gaps Summary\n\n### Critical Gaps (Affects Usability)\n\n1. **Examples Directories - 7 of 12 repos missing examples**\n - Missing: gmail/, calendar/, github/, fly/, neon/, vercel/, mcp-bridge/\n - Impact: Users must reverse-engineer from README snippets\n - Needed: Actual runnable code for each method\n\n2. **Troubleshooting Guides - 0 of 12 have them**\n - Missing: All daemons\n - Common issues: OAuth failures, API quota limits, token refresh, socket errors\n - Needed: FAQ with solutions to common problems\n\n3. **API Method Documentation**\n - Gmail: No schema for attachment handling, threading\n - Calendar: No timezone/recurring event docs\n - GitHub: No pagination, rate limiting, filtering details\n - Neon: No transaction, timeout, size limit docs\n - Vercel: No deployment status constants, secret handling\n\n4. **Error Handling Patterns**\n - Missing: How to handle specific errors (404, 403, timeout, etc.)\n - Missing: Retry strategies\n - Missing: Error response schema\n\n### Major Gaps (Affects Implementation)\n\n5. **Credential/Auth Setup**\n - Each daemon has different auth (OAuth, env vars, gh CLI)\n - Missing: Step-by-step console setup for each service\n - Missing: Token lifecycle (expiration, refresh, storage)\n - Missing: Unified auth documentation\n\n6. **Inline Code Documentation**\n - Browser: Excellent (~50 lines of examples in service.rs)\n - Gmail: Good (top-level comments)\n - Others: Minimal to none\n - Missing: Docstring comments on key functions\n\n7. **Test Coverage Documentation**\n - Only 3 repos have tests: browser (1), cli (1), daemon (3)\n - No test examples in README\n - Missing: How to write tests for custom daemons\n\n8. **Configuration Files**\n - Missing: ~/.fgp/config.json schema and examples\n - Missing: Service manifest.json format\n - Missing: Authentication token storage details\n\n### Moderate Gaps (Nice to Have)\n\n9. **Architecture Decision Records (ADRs)**\n - Why UNIX sockets vs TCP?\n - Why NDJSON vs Protocol Buffers?\n - Why separate daemons vs monolith?\n - Missing: Decision history\n\n10. **Integration Guides**\n - How to use multiple daemons together?\n - How to coordinate state across services?\n - Missing: Multi-daemon workflows\n\n11. **Performance Tuning**\n - Connection pooling strategies\n - Caching guidance\n - Batch operation patterns\n - Missing: Best practices guide\n\n12. **Deployment Guides**\n - How to deploy daemons to production?\n - Docker, systemd, launchd examples?\n - Missing: DevOps documentation\n\n---\n\n## Quality Metrics\n\n| Category | Score | Notes |\n|----------|-------|-------|\n| README Coverage | 7/10 | All repos have READMEs but varying quality |\n| Code Examples | 3/10 | Only 3 repos have examples (browser, cli, daemon-py) |\n| Inline Documentation | 6/10 | Rust code better than others; Python minimal |\n| Getting Started Guides | 6/10 | Installation covered; OAuth setup varies |\n| API Documentation | 5/10 | Methods listed, but no schemas or error docs |\n| Troubleshooting | 1/10 | Almost completely missing |\n| Tests | 2/10 | Only 3 repos have tests; no test guides |\n| Architecture Docs | 7/10 | Good at high level; missing deep dives |\n\n**Overall Documentation Score: 4.3/10**\n\n---\n\n## Recommendations by Priority\n\n### T0 (Critical - Blocks Users)\n\n1. **Create examples/ directories for all 7 service daemons**\n - gmail/examples/: OAuth flow, inbox read, search, send, attachment handling\n - calendar/examples/: list events, create event, find free slots\n - github/examples/: list repos, create issue, check PR status\n - fly/examples/: list apps, check status, update machine\n - neon/examples/: query database, manage branches, schema inspection\n - vercel/examples/: list projects, get deployment logs, deployment status\n - mcp-bridge/examples/: configure Claude Desktop, test tool discovery\n\n2. **Add troubleshooting sections to all service READMEs**\n - Common errors and solutions\n - OAuth timeout handling\n - API quota/rate limiting\n - Socket permission issues\n - Token refresh behavior\n\n3. **Document API method schemas for each service**\n - Request parameters with types\n - Response objects with examples\n - Error responses\n - Limits and constraints\n\n### T1 (Important - Improves DX)\n\n4. **Comprehensive auth setup guides**\n - Step-by-step credential creation for each service\n - Token lifecycle and refresh\n - Unified auth documentation\n - Security best practices\n\n5. **Add inline code documentation**\n - Service struct rustdocs\n - Method dispatch comments\n - Python method docstrings\n - Parameter validation details\n\n6. **Test examples and guides**\n - How to write tests for daemons\n - Example test files for each service\n - CI/CD testing patterns\n\n### T2 (Nice to Have - Polish)\n\n7. **Architecture documentation**\n - ADRs for design decisions\n - Performance design patterns\n - Concurrency models\n - Connection pooling strategies\n\n8. **Integration guides**\n - Multi-daemon workflows\n - State coordination\n - Dependency management\n\n9. **Deployment documentation**\n - Docker containers\n - systemd services\n - macOS launchd setup\n - Process management\n\n10. **Performance tuning guides**\n - Batch operation patterns\n - Caching strategies\n - Metrics and monitoring\n - Load testing\n\n---\n\n## By the Numbers\n\n| Metric | Status |\n|--------|--------|\n| Repos with README | 12/12 (100%) |\n| Repos with examples | 3/12 (25%) |\n| Repos with tests | 3/12 (25%) |\n| Repos with CHANGELOG | 5/12 (42%) |\n| Services with OAuth docs | 2/7 (29%) |\n| Services with error handling docs | 0/7 (0%) |\n| Services with integration examples | 0/7 (0%) |\n| Services with API schemas | 0/7 (0%) |\n\n---\n\n## File Paths for Reference\n\n**Well-documented:**\n- `/Users/wolfgangschoenberger/Projects/fgp/README.md` - Root overview\n- `/Users/wolfgangschoenberger/Projects/fgp/protocol/FGP-PROTOCOL.md` - Spec\n- `/Users/wolfgangschoenberger/Projects/fgp/browser/README.md` - Best service example\n- `/Users/wolfgangschoenberger/Projects/fgp/.github/CONTRIBUTING.md` - Contribution guide\n\n**Needs Improvement:**\n- `/Users/wolfgangschoenberger/Projects/fgp/calendar/README.md` - Only 83 lines\n- `/Users/wolfgangschoenberger/Projects/fgp/gmail/README.md` - Missing error handling\n- `/Users/wolfgangschoenberger/Projects/fgp/github/README.md` - Missing auth setup guide\n- `/Users/wolfgangschoenberger/Projects/fgp/daemon-py/README.md` - No CHANGELOG\n- `/Users/wolfgangschoenberger/Projects/fgp/cli/README.md` - No config documentation\n\n---\n\n## Conclusion\n\nThe FGP project has **strong foundation documentation** (root README, protocol spec, contribution guide) but **significant gaps in service-specific documentation**. The browser daemon exemplifies what complete documentation looks like. Most other services need:\n\n1. Practical examples (runnable code)\n2. Error handling and troubleshooting\n3. Detailed API schemas\n4. OAuth/credential setup guides\n5. Inline code documentation\n\nAddressing T0 items would significantly improve user experience and reduce support burden. Current documentation could block users from successfully using 5 of 7 service daemons without external research.\n\nFull transcript available at: /tmp/claude/-Users-wolfgangschoenberger-Projects-fgp/tasks/a080b89.output" + } +] \ No newline at end of file diff --git a/src/client.rs b/src/client.rs index 5a70eb5..bc9a9b6 100644 --- a/src/client.rs +++ b/src/client.rs @@ -29,9 +29,25 @@ use crate::protocol::{Request, Response}; /// println!("Health: {:?}", health); /// # Ok::<(), anyhow::Error>(()) /// ``` +/// +/// ## Auto-Start +/// +/// Use `FgpClient::for_service()` to create a client that automatically starts +/// the daemon if it's not running: +/// +/// ```rust,no_run +/// use fgp_daemon::FgpClient; +/// +/// // This will auto-start the gmail daemon if needed +/// let client = FgpClient::for_service("gmail")?; +/// let response = client.call("gmail.inbox", serde_json::json!({}))?; +/// # Ok::<(), anyhow::Error>(()) +/// ``` pub struct FgpClient { socket_path: PathBuf, timeout: Duration, + /// Service name for auto-start support + auto_start_service: Option, } impl FgpClient { @@ -44,6 +60,34 @@ impl FgpClient { Ok(Self { socket_path, timeout: Duration::from_secs(30), + auto_start_service: None, + }) + } + + /// Create a client for a named service with auto-start enabled. + /// + /// This is the recommended way to create a client. If the daemon is not running, + /// it will be automatically started on the first call. + /// + /// # Arguments + /// * `service_name` - Name of the service (e.g., "gmail", "browser", "calendar") + /// + /// # Example + /// + /// ```rust,no_run + /// use fgp_daemon::FgpClient; + /// + /// let client = FgpClient::for_service("gmail")?; + /// // If gmail daemon isn't running, it will be auto-started + /// let response = client.call("gmail.inbox", serde_json::json!({}))?; + /// # Ok::<(), anyhow::Error>(()) + /// ``` + pub fn for_service(service_name: &str) -> Result { + let socket_path = crate::lifecycle::service_socket_path(service_name); + Ok(Self { + socket_path, + timeout: Duration::from_secs(30), + auto_start_service: Some(service_name.to_string()), }) } @@ -53,6 +97,26 @@ impl FgpClient { self } + /// Enable auto-start for a specific service. + /// + /// When auto-start is enabled and the daemon is not running, the client + /// will attempt to start it automatically on the first call. + /// + /// # Arguments + /// * `service_name` - Name of the service to auto-start + pub fn with_auto_start(mut self, service_name: &str) -> Self { + self.auto_start_service = Some(service_name.to_string()); + self + } + + /// Disable auto-start. + /// + /// Calls will fail immediately if the daemon is not running. + pub fn without_auto_start(mut self) -> Self { + self.auto_start_service = None; + self + } + /// Call a daemon method. /// /// # Arguments @@ -105,10 +169,35 @@ impl FgpClient { /// Send a request and receive a response. fn send_request(&self, request: &Request) -> Result { - // Connect to socket - let mut stream = UnixStream::connect(&self.socket_path) - .with_context(|| format!("Cannot connect to daemon at {:?}", self.socket_path))?; + // Try to connect to socket + let stream = match UnixStream::connect(&self.socket_path) { + Ok(stream) => stream, + Err(e) => { + // Connection failed - try auto-start if configured + if let Some(ref service_name) = self.auto_start_service { + tracing::info!( + "Daemon not running, auto-starting service '{}'...", + service_name + ); + + // Start the service + crate::lifecycle::start_service(service_name) + .with_context(|| format!("Failed to auto-start service '{}'", service_name))?; + // Retry connection + UnixStream::connect(&self.socket_path) + .with_context(|| format!("Cannot connect to daemon at {:?} after auto-start", self.socket_path))? + } else { + return Err(e).with_context(|| format!("Cannot connect to daemon at {:?}", self.socket_path)); + } + } + }; + + self.send_request_on_stream(stream, request) + } + + /// Send request on an already-connected stream. + fn send_request_on_stream(&self, mut stream: UnixStream, request: &Request) -> Result { stream.set_read_timeout(Some(self.timeout))?; stream.set_write_timeout(Some(self.timeout))?; @@ -135,11 +224,15 @@ fn expand_path(path: &Path) -> Result { /// Convenience function to call a method on a daemon. /// +/// This does NOT auto-start the daemon. If the daemon is not running, the call +/// will fail. Use `call_auto_start()` if you want automatic daemon startup. +/// /// # Example /// /// ```rust,no_run /// use fgp_daemon::client::call; /// +/// // Fails if gmail daemon is not running /// let response = call("gmail", "gmail.list", serde_json::json!({"limit": 5}))?; /// # Ok::<(), anyhow::Error>(()) /// ``` @@ -149,12 +242,27 @@ pub fn call(service_name: &str, method: &str, params: serde_json::Value) -> Resu client.call(method, params) } +/// Call a method with auto-start enabled. +/// +/// If the daemon is not running, it will be started automatically. +/// +/// # Example +/// +/// ```rust,no_run +/// use fgp_daemon::client::call_auto_start; +/// +/// // Auto-starts gmail daemon if not running +/// let response = call_auto_start("gmail", "gmail.list", serde_json::json!({"limit": 5}))?; +/// # Ok::<(), anyhow::Error>(()) +/// ``` +pub fn call_auto_start(service_name: &str, method: &str, params: serde_json::Value) -> Result { + let client = FgpClient::for_service(service_name)?; + client.call(method, params) +} + /// Check if a daemon service is running. pub fn is_running(service_name: &str) -> bool { - let socket_path = crate::lifecycle::service_socket_path(service_name); - FgpClient::new(socket_path) - .map(|c| c.is_running()) - .unwrap_or(false) + crate::lifecycle::is_service_running(service_name) } #[cfg(test)] diff --git a/src/lib.rs b/src/lib.rs index b4aacba..008df78 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -61,7 +61,11 @@ pub use protocol::{Request, Response, ErrorInfo, ResponseMeta}; pub use server::FgpServer; pub use service::FgpService; pub use client::FgpClient; -pub use lifecycle::{daemonize, write_pid_file, cleanup_socket}; +pub use lifecycle::{ + daemonize, write_pid_file, cleanup_socket, + start_service, start_service_with_timeout, stop_service, is_service_running, + fgp_services_dir, service_socket_path, service_pid_path +}; #[cfg(feature = "python")] pub use python::PythonModule; diff --git a/src/lifecycle.rs b/src/lifecycle.rs index 9497888..a85d49f 100644 --- a/src/lifecycle.rs +++ b/src/lifecycle.rs @@ -1,11 +1,14 @@ //! Daemon lifecycle utilities. //! -//! Helpers for daemonizing processes, managing PID files, and socket cleanup. +//! Helpers for daemonizing processes, managing PID files, socket cleanup, +//! and on-demand service starting. -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, Instant}; /// Daemonize the current process. /// @@ -133,6 +136,41 @@ fn expand_path(path: &Path) -> Result { Ok(PathBuf::from(expanded.as_ref())) } +/// Validate that an entrypoint is safe to execute. +/// +/// Checks: +/// - File has executable permission +/// - File is owned by current user or root (not world-writable) +fn validate_entrypoint(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let metadata = fs::metadata(path) + .with_context(|| format!("Cannot read entrypoint metadata: {}", path.display()))?; + + let permissions = metadata.permissions(); + let mode = permissions.mode(); + + // Check if file is executable (user, group, or other) + if mode & 0o111 == 0 { + bail!( + "Entrypoint is not executable: {}. Run: chmod +x {}", + path.display(), + path.display() + ); + } + + // Security check: warn if world-writable (but don't block) + if mode & 0o002 != 0 { + tracing::warn!( + "Security warning: entrypoint {} is world-writable. Consider: chmod o-w {}", + path.display(), + path.display() + ); + } + + Ok(()) +} + /// Standard socket path for a service. pub fn service_socket_path(service_name: &str) -> PathBuf { let base = shellexpand::tilde("~/.fgp/services"); @@ -149,6 +187,217 @@ pub fn service_pid_path(service_name: &str) -> PathBuf { .join("daemon.pid") } +/// Get the FGP services base directory. +pub fn fgp_services_dir() -> PathBuf { + let base = shellexpand::tilde("~/.fgp/services"); + PathBuf::from(base.as_ref()) +} + +/// Start a daemon service on-demand. +/// +/// This function: +/// 1. Reads the service manifest from `~/.fgp/services/{service}/manifest.json` +/// 2. Spawns the daemon entrypoint process +/// 3. Waits for the socket to appear (with timeout) +/// +/// # Arguments +/// * `service_name` - Name of the service to start (e.g., "gmail", "browser") +/// +/// # Example +/// +/// ```rust,no_run +/// use fgp_daemon::lifecycle::start_service; +/// +/// // Start the gmail daemon +/// start_service("gmail")?; +/// # Ok::<(), anyhow::Error>(()) +/// ``` +pub fn start_service(service_name: &str) -> Result<()> { + start_service_with_timeout(service_name, Duration::from_secs(5)) +} + +/// Start a daemon service with a custom timeout. +/// +/// # Arguments +/// * `service_name` - Name of the service to start +/// * `timeout` - Maximum time to wait for socket to appear +pub fn start_service_with_timeout(service_name: &str, timeout: Duration) -> Result<()> { + let service_dir = fgp_services_dir().join(service_name); + + // Check if service is installed + let manifest_path = service_dir.join("manifest.json"); + if !manifest_path.exists() { + bail!( + "Service '{}' is not installed. Run 'fgp install ' first.", + service_name + ); + } + + // Check if already running + let socket_path = service_socket_path(service_name); + if socket_path.exists() { + // Try to connect to see if it's actually running + if std::os::unix::net::UnixStream::connect(&socket_path).is_ok() { + tracing::debug!("Service '{}' is already running", service_name); + return Ok(()); + } else { + // Stale socket, remove it + let _ = fs::remove_file(&socket_path); + } + } + + // Read manifest to get entrypoint + let manifest_content = fs::read_to_string(&manifest_path) + .context("Failed to read manifest.json")?; + let manifest: serde_json::Value = serde_json::from_str(&manifest_content) + .context("Failed to parse manifest.json")?; + + let entrypoint = manifest["daemon"]["entrypoint"] + .as_str() + .context("manifest.json missing daemon.entrypoint")?; + + let entrypoint_path = service_dir.join(entrypoint); + if !entrypoint_path.exists() { + bail!("Daemon entrypoint not found: {}", entrypoint_path.display()); + } + + // Security: Validate entrypoint is executable + validate_entrypoint(&entrypoint_path)?; + + tracing::info!("Starting service '{}'...", service_name); + + // Start as background process + let _child = Command::new(&entrypoint_path) + .current_dir(&service_dir) + .spawn() + .context("Failed to start daemon")?; + + // Wait for socket to appear with timeout + let start = Instant::now(); + while start.elapsed() < timeout { + if socket_path.exists() { + // Verify we can connect + if std::os::unix::net::UnixStream::connect(&socket_path).is_ok() { + tracing::info!("Service '{}' started successfully", service_name); + return Ok(()); + } + } + std::thread::sleep(Duration::from_millis(50)); + } + + bail!( + "Service '{}' started but socket not ready within {:?}", + service_name, + timeout + ) +} + +/// Stop a daemon service. +/// +/// Sends SIGTERM to the daemon process and cleans up socket/PID files. +/// +/// # Arguments +/// * `service_name` - Name of the service to stop +pub fn stop_service(service_name: &str) -> Result<()> { + let socket_path = service_socket_path(service_name); + let pid_path = service_pid_path(service_name); + + if socket_path.exists() { + if let Ok(client) = crate::client::FgpClient::new(&socket_path) { + if let Ok(response) = client.stop() { + if response.ok { + return Ok(()); + } + } + } + } + + // Check if PID file exists + if let Some(pid) = read_pid_file(&pid_path) { + if is_process_running(pid) { + tracing::info!("Stopping service '{}' (PID: {})...", service_name, pid); + + let expected = read_entrypoint_name(service_name)?; + if !pid_matches_process(pid, expected.as_deref()) { + bail!( + "Refusing to stop PID {}: process does not match expected entrypoint '{}'", + pid, + expected.unwrap_or_else(|| "unknown".to_string()) + ); + } + + // Send SIGTERM + unsafe { + libc::kill(pid as i32, libc::SIGTERM); + } + + // Wait a moment for graceful shutdown + std::thread::sleep(Duration::from_millis(500)); + } + } + + // Clean up files + let _ = fs::remove_file(&socket_path); + let _ = fs::remove_file(&pid_path); + + tracing::info!("Service '{}' stopped", service_name); + Ok(()) +} + +fn read_entrypoint_name(service_name: &str) -> Result> { + let manifest_path = fgp_services_dir().join(service_name).join("manifest.json"); + if !manifest_path.exists() { + return Ok(None); + } + + let manifest_content = fs::read_to_string(&manifest_path) + .context("Failed to read manifest.json")?; + let manifest: serde_json::Value = serde_json::from_str(&manifest_content) + .context("Failed to parse manifest.json")?; + + let entrypoint = manifest["daemon"]["entrypoint"] + .as_str() + .map(|s| s.to_string()); + + Ok(entrypoint.and_then(|p| { + Path::new(&p) + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.to_string()) + })) +} + +fn pid_matches_process(pid: u32, expected_name: Option<&str>) -> bool { + let Some(expected_name) = expected_name else { + return false; + }; + + let output = Command::new("ps") + .args(["-p", &pid.to_string(), "-o", "comm="]) + .output(); + + match output { + Ok(output) if output.status.success() => { + let command = String::from_utf8_lossy(&output.stdout); + command.trim().contains(expected_name) + } + _ => false, + } +} + +/// Check if a service is currently running. +/// +/// # Arguments +/// * `service_name` - Name of the service to check +pub fn is_service_running(service_name: &str) -> bool { + let socket_path = service_socket_path(service_name); + if socket_path.exists() { + std::os::unix::net::UnixStream::connect(&socket_path).is_ok() + } else { + false + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/server.rs b/src/server.rs index 5ed5a30..e294d4c 100644 --- a/src/server.rs +++ b/src/server.rs @@ -3,6 +3,7 @@ //! The [`FgpServer`] handles socket creation, connection management, and request dispatch. use anyhow::Result; +use chrono::{SecondsFormat, Utc}; use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; @@ -42,6 +43,7 @@ pub struct FgpServer { service: Arc, socket_path: PathBuf, started_at: Arc, + started_at_iso: Arc, running: Arc, } @@ -53,6 +55,7 @@ impl FgpServer { /// * `socket_path` - Path to the UNIX socket (supports `~` expansion) pub fn new(service: S, socket_path: impl AsRef) -> Result { let socket_path = expand_path(socket_path.as_ref())?; + let started_at_iso = Arc::new(Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)); // Create parent directory if needed if let Some(parent) = socket_path.parent() { @@ -63,6 +66,7 @@ impl FgpServer { service: Arc::new(service), socket_path, started_at: Arc::new(Instant::now()), + started_at_iso, running: Arc::new(AtomicBool::new(false)), }) } @@ -112,10 +116,17 @@ impl FgpServer { // Clone Arcs for the spawned thread let service = Arc::clone(&self.service); let started_at = Arc::clone(&self.started_at); + let started_at_iso = Arc::clone(&self.started_at_iso); let running = Arc::clone(&self.running); thread::spawn(move || { - if let Err(e) = Self::handle_connection_static(stream, &service, &started_at, &running) { + if let Err(e) = Self::handle_connection_static( + stream, + &service, + &started_at, + &started_at_iso, + &running, + ) { error!(error = %e, "Connection error"); } }); @@ -144,7 +155,13 @@ impl FgpServer { /// Handle a single client connection (instance method - calls static version). #[allow(dead_code)] fn handle_connection(&self, stream: UnixStream) -> Result<()> { - Self::handle_connection_static(stream, &self.service, &self.started_at, &self.running) + Self::handle_connection_static( + stream, + &self.service, + &self.started_at, + &self.started_at_iso, + &self.running, + ) } /// Handle a single client connection (static version for thread spawning). @@ -152,130 +169,158 @@ impl FgpServer { stream: UnixStream, service: &Arc, started_at: &Arc, + started_at_iso: &Arc, running: &Arc, ) -> Result<()> { let writer_stream = stream.try_clone()?; let mut reader = BufReader::new(&stream); let mut writer = writer_stream; - // Read NDJSON request (one line) + // Read NDJSON requests (one line at a time) let mut line = String::new(); - reader.read_line(&mut line)?; + loop { + line.clear(); + let bytes = reader.read_line(&mut line)?; + if bytes == 0 { + return Ok(()); // Client disconnected + } - if line.trim().is_empty() { - return Ok(()); // Client disconnected - } + if line.trim().is_empty() { + continue; + } + + let start = Instant::now(); - let start = Instant::now(); + // Parse request + let request = match protocol::Request::from_ndjson_line(&line) { + Ok(req) => req, + Err(e) => { + let response = Response::error( + "null", + error_codes::INVALID_REQUEST, + format!("Failed to parse request: {}", e), + start.elapsed().as_secs_f64() * 1000.0, + ); + let response_line = response.to_ndjson_line()?; + writer.write_all(response_line.as_bytes())?; + writer.flush()?; + continue; + } + }; - // Parse request - let request = match protocol::Request::from_ndjson_line(&line) { - Ok(req) => req, - Err(e) => { + if request.v != crate::PROTOCOL_VERSION { let response = Response::error( - "null", + &request.id, error_codes::INVALID_REQUEST, - format!("Failed to parse request: {}", e), + format!( + "Unsupported protocol version: {} (expected {})", + request.v, + crate::PROTOCOL_VERSION + ), start.elapsed().as_secs_f64() * 1000.0, ); let response_line = response.to_ndjson_line()?; writer.write_all(response_line.as_bytes())?; writer.flush()?; - return Ok(()); + continue; } - }; - let method = request.method.as_str(); - let service_prefix = format!("{}.", service.name()); - let is_namespaced_for_service = method.starts_with(&service_prefix); - let action = if is_namespaced_for_service { - &method[service_prefix.len()..] - } else { - method - }; - - debug!( - method = %request.method, - id = %request.id, - "Handling request" - ); - - // Dispatch to service or handle built-in methods. Built-ins may be called as either: - // - "health" / "methods" / "stop" (preferred) - // - ".health" / ".methods" / ".stop" (accepted for compatibility) - let response = match action { - "health" if method == "health" || is_namespaced_for_service => { - Self::handle_health_static(&request.id, start, service, started_at) - } - "stop" if method == "stop" || is_namespaced_for_service => { - running.store(false, Ordering::SeqCst); - Response::success( - &request.id, - serde_json::json!({"message": "Shutting down"}), - start.elapsed().as_secs_f64() * 1000.0, - ) - } - "methods" if method == "methods" || is_namespaced_for_service => { - Self::handle_methods_static(&request.id, start, service) - } - _ => { - if method.contains('.') && !is_namespaced_for_service { - Response::error( + let method = request.method.as_str(); + let service_prefix = format!("{}.", service.name()); + let is_namespaced_for_service = method.starts_with(&service_prefix); + let action = if is_namespaced_for_service { + &method[service_prefix.len()..] + } else { + method + }; + + debug!( + method = %request.method, + id = %request.id, + "Handling request" + ); + + // Dispatch to service or handle built-in methods. Built-ins may be called as either: + // - "health" / "methods" / "stop" (preferred) + // - ".health" / ".methods" / ".stop" (accepted for compatibility) + let response = match action { + "health" if method == "health" || is_namespaced_for_service => { + Self::handle_health_static(&request.id, start, service, started_at, started_at_iso) + } + "stop" if method == "stop" || is_namespaced_for_service => { + running.store(false, Ordering::SeqCst); + Response::success( &request.id, - error_codes::INVALID_REQUEST, - format!( - "Method namespace must match service '{}': got '{}'", - service.name(), - method - ), + serde_json::json!({"message": "Shutting down"}), start.elapsed().as_secs_f64() * 1000.0, ) - } else { - // Normalize to fully-qualified method names for the service dispatch. - let dispatch_method = if is_namespaced_for_service { - request.method.clone() - } else if method.contains('.') { - // Already handled mismatch above, so this is unreachable. - request.method.clone() - } else { - format!("{}{}", service_prefix, method) - }; - - debug!( - request_method = %request.method, - dispatch_method = %dispatch_method, - id = %request.id, - "Dispatching request" - ); - - match service.dispatch(&dispatch_method, request.params) { - Ok(result) => Response::success( - &request.id, - result, - start.elapsed().as_secs_f64() * 1000.0, - ), - Err(e) => Response::error( + } + "methods" if method == "methods" || is_namespaced_for_service => { + Self::handle_methods_static(&request.id, start, service) + } + _ => { + if method.contains('.') && !is_namespaced_for_service { + Response::error( &request.id, - error_codes::INTERNAL_ERROR, - e.to_string(), + error_codes::INVALID_REQUEST, + format!( + "Method namespace must match service '{}': got '{}'", + service.name(), + method + ), start.elapsed().as_secs_f64() * 1000.0, - ), + ) + } else { + // Normalize to fully-qualified method names for the service dispatch. + let dispatch_method = if is_namespaced_for_service { + request.method.clone() + } else if method.contains('.') { + // Already handled mismatch above, so this is unreachable. + request.method.clone() + } else { + format!("{}{}", service_prefix, method) + }; + + debug!( + request_method = %request.method, + dispatch_method = %dispatch_method, + id = %request.id, + "Dispatching request" + ); + + match service.dispatch(&dispatch_method, request.params) { + Ok(result) => Response::success( + &request.id, + result, + start.elapsed().as_secs_f64() * 1000.0, + ), + Err(e) => Response::error( + &request.id, + error_codes::INTERNAL_ERROR, + e.to_string(), + start.elapsed().as_secs_f64() * 1000.0, + ), + } } } - } - }; + }; - // Send NDJSON response - let response_line = response.to_ndjson_line()?; - writer.write_all(response_line.as_bytes())?; - writer.flush()?; + // Send NDJSON response + let response_line = response.to_ndjson_line()?; + writer.write_all(response_line.as_bytes())?; + writer.flush()?; - debug!( - method = %request.method, - id = %request.id, - server_ms = response.meta.server_ms, - "Request complete" - ); + debug!( + method = %request.method, + id = %request.id, + server_ms = response.meta.server_ms, + "Request complete" + ); + + if !running.load(Ordering::SeqCst) { + break; + } + } Ok(()) } @@ -283,11 +328,17 @@ impl FgpServer { /// Handle the `health` built-in method (instance version). #[allow(dead_code)] fn handle_health(&self, id: &str, start: Instant) -> Response { - Self::handle_health_static(id, start, &self.service, &self.started_at) + Self::handle_health_static(id, start, &self.service, &self.started_at, &self.started_at_iso) } /// Handle the `health` built-in method (static version). - fn handle_health_static(id: &str, start: Instant, service: &Arc, started_at: &Arc) -> Response { + fn handle_health_static( + id: &str, + start: Instant, + service: &Arc, + started_at: &Arc, + started_at_iso: &Arc, + ) -> Response { let uptime = started_at.elapsed().as_secs(); let services = service.health_check(); @@ -307,7 +358,7 @@ impl FgpServer { serde_json::json!({ "status": status, "pid": std::process::id(), - "started_at": chrono_now_iso(), + "started_at": started_at_iso.as_str(), "version": service.version(), "uptime_seconds": uptime, "services": services, @@ -364,14 +415,3 @@ fn expand_path(path: &Path) -> Result { let expanded = shellexpand::tilde(&path_str); Ok(PathBuf::from(expanded.as_ref())) } - -/// Get current time as ISO 8601 string (without chrono dependency). -fn chrono_now_iso() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let duration = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default(); - let secs = duration.as_secs(); - // Simple ISO format without full chrono - format!("{}Z", secs) -}