diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 837e1da..5889cb8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -74,7 +74,7 @@ responsibilities isolated. ```text main - ├── config + doctor + assistant init + ├── paths + config + doctor + assistant init ├── gateway │ ├── channel │ │ ├── imessage @@ -174,15 +174,26 @@ Push uses separate stores because they have different update and query needs: | Store | Purpose | | --- | --- | -| `state.json` | small atomically replaced map of channel cursors and backend session IDs | -| `push.db` | transactional conversation, delivery, approval compatibility, and job-run history | -| `.slack-inbox.db` | Slack Socket Mode inbox committed before envelope acknowledgement | -| `audit.jsonl` | append-only operational audit events | +| `$PUSH_HOME/state.json` | small atomically replaced map of channel cursors and backend session IDs | +| `$PUSH_HOME/push.db` | transactional conversation, delivery, approval compatibility, and job-run history | +| `$PUSH_HOME/state.json.slack-inbox.db` | Slack Socket Mode inbox committed before envelope acknowledgement | +| `$PUSH_HOME/audit.jsonl` | append-only operational audit events | +| `$PUSH_HOME/run/` | advisory job locks | +| `$PUSH_HOME/cache/` | disposable agent handoff files | | assistant Git repository | user-owned identity, context, evals, jobs, and project resources | Runtime databases, secrets, locks, sessions, and logs must stay outside the assistant repository. +[`src/paths.rs`](src/paths.rs) is the single owner of runtime locations. +`PUSH_HOME` selects the root and defaults to `~/.push`. Config, startup, jobs, +history, audit, and channels consume the resolved `PushPaths` value instead of +reconstructing filenames. The config, database, state, audit, run-lock, inbox, +and cache locations are derived from that root. Explicit legacy state, +database, audit, and run-lock settings replace only their matching derived +locations. `assistant_root` remains independent, and overlap with the runtime +root fails closed. + --- ## 3. Process Lifecycle @@ -192,8 +203,9 @@ The entry point is [`src/main.rs`](src/main.rs). ### Step 1: Parse the Command The binary supports the long-running gateway plus `init`, `doctor`, `reload`, -and job management commands. Commands that need configuration resolve -`--config`, defaulting to `~/.push/config.toml`. +and job management commands. Commands that need configuration use `--config` +when present. `--config` selects a file without changing `PUSH_HOME`; +otherwise the config is `$PUSH_HOME/config.toml`. ### Step 2: Load and Validate Configuration diff --git a/README.md b/README.md index ed8a075..e2ed2df 100644 --- a/README.md +++ b/README.md @@ -96,9 +96,10 @@ push init ~/Code/assistant This creates a Git repository containing `SOUL.md`, shared instructions in `AGENTS.md`, a `CLAUDE.md` reference to those instructions, `context/`, `evals/`, `jobs/`, and a versioned Push capability skill for Claude Code, Codex, and Pi. -It then records the repository path in `~/.push/config.toml`. +It then records the repository path in `$PUSH_HOME/config.toml`. `PUSH_HOME` +defaults to `~/.push`. -Edit `~/.push/config.toml` to connect a chat channel. A small Telegram setup +Edit `$PUSH_HOME/config.toml` to connect a chat channel. A small Telegram setup looks like this: ```toml diff --git a/docs/configuration.md b/docs/configuration.md index d9ffd10..d9d9fc9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,7 +1,8 @@ # Configuration -Push reads TOML from `~/.push/config.toml` by default. Pass `--config ` -to use a different file for a gateway, doctor, init, or job command. +Push owns one runtime root. `PUSH_HOME` selects it and defaults to `~/.push`. +The default config is `$PUSH_HOME/config.toml`. Pass `--config ` to use a +different file for a gateway, doctor, init, or job command. ```sh push doctor @@ -12,6 +13,20 @@ Paths beginning with `~` are expanded. Invalid values, unknown fields inside provider sections, unsafe path overlap, and removed gateway permission settings fail configuration load with an actionable error. +The path precedence is: + +1. `--config` selects the config file when present. +2. `PUSH_HOME` selects the runtime root, even when `--config` selects a file elsewhere. +3. Explicit `state_path`, `database_path`, `audit_log_path`, and + `jobs_run_dir` settings override their matching derived locations. +4. Without `PUSH_HOME`, Push derives the root from `HOME` as `~/.push`. + +If neither `PUSH_HOME` nor `HOME` is available, commands that need +configuration fail with an instruction to set `PUSH_HOME`. Help and version +commands still work. Use distinct `PUSH_HOME` values to run isolated Push +installations. `assistant_root` is never derived from `PUSH_HOME`; keep the +assistant repository outside the runtime root. + Create the one assistant repository and persist its root before editing the rest of the config: @@ -241,9 +256,9 @@ requests. Review [permissions and security](security.md) before enabling jobs. | Setting | Default | Purpose | | --- | --- | --- | | `assistant_root` | required for new setups | Canonical root of the one assistant repository; `SOUL.md`, `context/`, `evals/`, and `jobs/` are derived | -| `state_path` | `~/.push/state.json` | Channel cursors and backend session IDs | -| `database_path` | `~/.push/push.db` | Canonical conversation, approval, and job history | -| `audit_log_path` | `~/.push/audit.jsonl` | Structured local audit log | +| `state_path` | `$PUSH_HOME/state.json` | Channel cursors and backend session IDs | +| `database_path` | `$PUSH_HOME/push.db` | Canonical conversation, approval, and job history | +| `audit_log_path` | `$PUSH_HOME/audit.jsonl` | Structured local audit log | | `audit_log_content` | `false` | Include message and reply content in audit events | ### Jobs @@ -252,7 +267,7 @@ requests. Review [permissions and security](security.md) before enabling jobs. | --- | --- | --- | | `jobs_agent` | root `agent` | Default jobs backend | | `jobs_max_timeout` | `"30m"` | Maximum accepted job timeout | -| `jobs_run_dir` | `~/.push/run` | Local advisory locks | +| `jobs_run_dir` | `$PUSH_HOME/run` | Local advisory locks | | `jobs_max_workers` | `2` | Concurrent scheduled job workers | Push validates that runtime state, locks, external config files, and job work @@ -260,6 +275,12 @@ directories do not overlap in unsafe ways. Runtime state and secrets must stay outside the Git-versioned assistant repository. +Push also derives the Slack recovery inbox as +`$PUSH_HOME/state.json.slack-inbox.db` and uses `$PUSH_HOME/cache` for +disposable agent handoff files. When `state_path` is explicitly set, the Slack +inbox stays beside it as `.slack-inbox.db` to preserve existing +installations. + ## Complete example ```toml @@ -299,3 +320,11 @@ Legacy `assistant_dir` and `jobs_dir` settings remain compatible only when the jobs path is exactly `/jobs`. For separate legacy paths, move `SOUL.md`, context, and jobs under one directory and replace both settings with `assistant_root`. + +### Runtime path compatibility + +Explicit `state_path`, `database_path`, `audit_log_path`, and `jobs_run_dir` +settings remain supported for the rest of the 0.x release line. Push does not +move or rewrite data at those paths. A future removal would require an +announced major release and migration instructions. New installations should +omit them and let `PUSH_HOME` own the whole runtime layout. diff --git a/docs/core-system/design.md b/docs/core-system/design.md index 252b36b..dc6a6c7 100644 --- a/docs/core-system/design.md +++ b/docs/core-system/design.md @@ -117,7 +117,7 @@ actionable migration error rather than silently losing identity or jobs. ### Canonical conversation history -`~/.push/push.db` stores every accepted inbound message and every user-visible +`$PUSH_HOME/push.db` stores every accepted inbound message and every user-visible outbound message, whether produced by a backend or by the gateway. The minimum logical model is: diff --git a/docs/getting-started.md b/docs/getting-started.md index c6e449f..b680293 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -56,6 +56,14 @@ your shell does not already include it. The installer recognizes Intel macOS and ARM Linux, but it exits unless the latest GitHub release contains a matching archive. +Push stores its private runtime data under `PUSH_HOME`, which defaults to +`~/.push`. Set an absolute `PUSH_HOME` before `push init` when you want another +location or an isolated second installation: + +```sh +export PUSH_HOME="$HOME/.push-work" +``` + ## Build from source Use this path on other Rust-supported architectures or when testing `main`: @@ -91,7 +99,7 @@ practical structure for identity, context, shared skills, jobs, and evals. === "Telegram" Create a bot with Telegram's `@BotFather`, send it one message, and find - your stable numeric user ID. Then edit `~/.push/config.toml`: + your stable numeric user ID. Then edit `$PUSH_HOME/config.toml`: ```toml channel = "telegram" @@ -109,7 +117,7 @@ practical structure for identity, context, shared skills, jobs, and evals. === "iMessage" Give the terminal or service host Full Disk Access in macOS System - Settings, then edit `~/.push/config.toml`: + Settings, then edit `$PUSH_HOME/config.toml`: ```toml channel = "imessage" @@ -129,7 +137,7 @@ practical structure for identity, context, shared skills, jobs, and evals. Create a Slack app with Socket Mode, `connections:write`, `im:history`, `chat:write`, and the `message.im` bot event. Set the two tokens in the - service environment, then edit `~/.push/config.toml`: + service environment, then edit `$PUSH_HOME/config.toml`: ```toml channel = "slack" diff --git a/docs/jobs/design.md b/docs/jobs/design.md index ed6a812..0edb942 100644 --- a/docs/jobs/design.md +++ b/docs/jobs/design.md @@ -104,7 +104,7 @@ Scheduled example: +++ version = 1 timeout = "5m" -workdir = "~/.push/workspaces/morning-agenda" +workdir = "~/Code/morning-agenda" [[triggers]] id = "weekday-morning" @@ -183,7 +183,7 @@ built-in utility tools. The first version evaluates the returned response without inspecting work-directory artifacts. Before backend execution, both manual and scheduled starts attempt a -non-blocking OS advisory lock for the job under `~/.push/run/locks/`. The +non-blocking OS advisory lock for the job under `$PUSH_HOME/run/locks/`. The winning process holds that lock until its run finishes. It then uses one SQLite transaction to check queued and running state, record the run, and claim the job. A start that loses the file lock or database claim is recorded as diff --git a/docs/prd.md b/docs/prd.md index d305b50..4f9996e 100644 --- a/docs/prd.md +++ b/docs/prd.md @@ -151,7 +151,7 @@ user prompt. | `assistant_root` | Canonical root of the one assistant repository. `SOUL.md`, `context/`, and `jobs/` are derived. | | `jobs_agent` | Optional default job backend; otherwise uses `agent`. | | `jobs_max_timeout` | Maximum validated job timeout; defaults to `30m`. | -| `jobs_run_dir` | Local advisory-lock state; defaults to `~/.push/run`. | +| `jobs_run_dir` | Local advisory-lock state; defaults to `$PUSH_HOME/run`. | | `jobs_max_workers` | Maximum concurrent scheduled jobs; defaults to `2`. | | `imessage.db_path` | Path to Messages `chat.db`. | | `poll_interval` | How often to poll. | @@ -164,7 +164,7 @@ user prompt. | `state_path` | JSON state path. | | `audit_log_path` | Local JSONL audit log path. | | `audit_log_content` | Whether audit events include message and reply text. | -| `database_path` | Canonical SQLite history path; defaults to `~/.push/push.db`. | +| `database_path` | Canonical SQLite history path; defaults to `$PUSH_HOME/push.db`. | ## Control Commands diff --git a/docs/reference/cli.md b/docs/reference/cli.md index e5e37b7..fad5b0b 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -2,7 +2,8 @@ Push has one gateway command, one diagnostic command, and a small set of job commands. All commands accept `--config ` anywhere in the argument list. -The default is `~/.push/config.toml`. +The default is `$PUSH_HOME/config.toml`; `PUSH_HOME` defaults to `~/.push`. +`--config` changes only the selected config file, not the runtime root. | Command | Purpose | | --- | --- | diff --git a/docs/security.md b/docs/security.md index 2c0cf7e..0b58c0c 100644 --- a/docs/security.md +++ b/docs/security.md @@ -60,11 +60,14 @@ service environment using the narrowest policy that works. | Path | Contains | | --- | --- | -| `config.toml` | allowlists, routes, paths, and possibly credentials | +| `$PUSH_HOME/config.toml` | allowlists, routes, paths, and possibly credentials | | `/` | Git-versioned identity, context, evals, jobs, and optional project skills | -| `~/.push/state.json` | channel cursors and backend session IDs | -| `~/.push/push.db` | conversation history, approvals, and job runs | -| `~/.push/audit.jsonl` | metadata, errors, handles, and optional content | +| `$PUSH_HOME/state.json` | channel cursors and backend session IDs | +| `$PUSH_HOME/push.db` | conversation history, approvals, and job runs | +| `$PUSH_HOME/state.json.slack-inbox.db` | Slack envelopes accepted before gateway processing | +| `$PUSH_HOME/audit.jsonl` | metadata, errors, handles, and optional content | +| `$PUSH_HOME/run/` | local job lock files | +| `$PUSH_HOME/cache/` | disposable agent handoff files | Keep them on local durable storage with permissions restricted to the service user. Keep the assistant directory in its own private Git repository. Never @@ -75,9 +78,13 @@ variable or move the config outside. When `voice.openai_api_key` is configured, `push doctor` requires the config file to be private on Unix: ```sh -chmod 600 ~/.push/config.toml +chmod 600 "${PUSH_HOME:-$HOME/.push}/config.toml" ``` +Push rejects any overlap between `assistant_root` and `PUSH_HOME`, including +overlap through symlinks or existing ancestors. Keep the runtime root private +to Push and keep the assistant as a separate user-owned Git repository. + ## Network exposure Push opens no inbound server port. iMessage reads local state. Telegram uses @@ -110,7 +117,7 @@ IDs, file paths, and backend errors. Protect and rotate it like a service log. - run `push doctor` as the service user - keep agent credentials out of TOML and all credentials out of logs - protect config credentials with mode `0600` on Unix -- use absolute config paths in service files +- set one absolute `PUSH_HOME` in service files - keep Push state and job work directories separate - review agent-authored job changes in the assistant repository - review the audit log after routing or agent permission changes diff --git a/docs/services.md b/docs/services.md index c483894..48ee7a5 100644 --- a/docs/services.md +++ b/docs/services.md @@ -14,17 +14,14 @@ own the service: ```sh push init ~/Code/assistant -# Edit ~/.push/config.toml with your channel settings. +# Edit $PUSH_HOME/config.toml with your channel settings. push doctor ``` -Use absolute paths in service files. The service user needs: +Set one absolute `PUSH_HOME` in the service definition. It defaults to +`~/.push` for interactive commands. The service user needs: -- access to the configured `config.toml` -- write access to `state_path` -- write access to `audit_log_path` -- write access to `database_path` -- write access to `jobs_run_dir` +- read and write access to `PUSH_HOME` - filesystem access to `assistant_root` as allowed by the selected agent - agent write access to `assistant_root/jobs/` when jobs should be created from chat - access to the selected `claude`, `codex`, or `pi` executable on `PATH` @@ -38,11 +35,13 @@ Use absolute paths in service files. The service user needs: `OPENAI_API_KEY` in the service environment, plus network access to `api.openai.com` -`state_path` stores independent cursors for each channel and backend session -ids. `database_path` stores the canonical conversation journal. Chat agents run -from `assistant_root`. Keep these paths on durable storage. Restarting the -service resumes after the last completed row and reuses existing backend -sessions when the backend for that thread has not changed. +`$PUSH_HOME/state.json` stores independent cursors for each channel and backend +session IDs. `$PUSH_HOME/push.db` stores the canonical conversation and job +journal. The audit log, Slack recovery inbox, job locks, and cache are derived +from the same root. Chat agents run from `assistant_root`. Keep `PUSH_HOME` on +durable storage. Restarting the service resumes after the last completed row +and reuses existing backend sessions when the backend for that thread has not +changed. Keep `assistant_root` in its own Git repository. Keep config secrets, state, databases, logs, locks, and service credentials outside it. @@ -71,8 +70,6 @@ and replace `YOU` with your macOS user name: ProgramArguments /Users/YOU/.local/bin/push - --config - /Users/YOU/.push/config.toml WorkingDirectory @@ -80,6 +77,8 @@ and replace `YOU` with your macOS user name: EnvironmentVariables + PUSH_HOME + /Users/YOU/.push PATH /Users/YOU/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin @@ -147,11 +146,12 @@ Wants=network-online.target [Service] Type=simple -ExecStart=%h/.local/bin/push --config %h/.push/config.toml +ExecStart=%h/.local/bin/push WorkingDirectory=%h/.push Restart=on-failure RestartSec=10 Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +Environment=PUSH_HOME=%h/.push EnvironmentFile=-%h/.config/push/env [Install] @@ -195,7 +195,7 @@ loginctl enable-linger "$USER" ## Manual Jobs `push job run ` executes in the invoking terminal process, not in the -managed service. Use the same config file so the CLI and service share +managed service. Use the same `PUSH_HOME` so the CLI and service share `push.db`, `/jobs`, and the local per-job lock directory. Invalid job files are reported and disabled individually; they do not stop the messaging service. @@ -228,6 +228,21 @@ Ignored messages, completed rows, and setup failures advance the cursor. Rows newer than an in-flight row do not push the cursor past it until the earlier row is completed. +## Backup and Recovery + +Stop the service before taking a filesystem-level backup. Back up the complete +`PUSH_HOME` directory as one unit so config, cursors, the Slack inbox, canonical +history, audit events, and job delivery state stay consistent. Back up +`assistant_root` separately through its Git repository because it is +user-owned and must not live under `PUSH_HOME`. + +To restore, stop the service, restore both locations to separate directories, +set the service `PUSH_HOME` to the restored runtime root, confirm +`assistant_root` in the restored config, then run `push doctor` before starting +the service. If a compatible older config sets `state_path`, `database_path`, +`audit_log_path`, or `jobs_run_dir`, back up and restore those explicit +locations too. The cache directory is disposable and can be omitted. + ## Security Notes Managed services run without a person watching the terminal. An allowed sender diff --git a/docs/slack.md b/docs/slack.md index 9d4acd0..1f78708 100644 --- a/docs/slack.md +++ b/docs/slack.md @@ -43,7 +43,7 @@ You can instead set `slack.app_token` and `slack.bot_token` in the private Push config. Never put tokens in the Git-versioned assistant repository. Run: ```sh -chmod 600 ~/.push/config.toml +chmod 600 "${PUSH_HOME:-$HOME/.push}/config.toml" push doctor push ``` diff --git a/docs/telegram.md b/docs/telegram.md index 22cd60d..1e21e7e 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -9,7 +9,7 @@ server port. Telegram documents the polling contract in the official 1. Open a private chat with Telegram's official `@BotFather` account. 2. Send `/newbot` and follow the prompts. -3. Store the bot token in `~/.push/config.toml`. Keep that file private and do +3. Store the bot token in `$PUSH_HOME/config.toml`. Keep that file private and do not commit it, paste it into issue comments, or print it in logs. 4. Send one private message to the new bot. 5. Find your stable numeric user and chat ids from a trusted Bot API @@ -52,7 +52,7 @@ Telegram-only preflight does not open `chat.db` and does not require macOS or ## Voice Messages -Add the optional OpenAI API key to `~/.push/config.toml` to enable both incoming +Add the optional OpenAI API key to `$PUSH_HOME/config.toml` to enable both incoming voice transcription and spoken replies: ```toml diff --git a/examples/launchd/com.owainlewis.push.plist b/examples/launchd/com.owainlewis.push.plist index 121abfe..e62920a 100644 --- a/examples/launchd/com.owainlewis.push.plist +++ b/examples/launchd/com.owainlewis.push.plist @@ -9,8 +9,6 @@ ProgramArguments /Users/YOU/.local/bin/push - --config - /Users/YOU/.push/config.toml WorkingDirectory @@ -18,6 +16,8 @@ EnvironmentVariables + PUSH_HOME + /Users/YOU/.push PATH /Users/YOU/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin diff --git a/examples/systemd/push.service b/examples/systemd/push.service index bf3d2e3..c07a465 100644 --- a/examples/systemd/push.service +++ b/examples/systemd/push.service @@ -5,11 +5,12 @@ Wants=network-online.target [Service] Type=simple -ExecStart=%h/.local/bin/push --config %h/.push/config.toml +ExecStart=%h/.local/bin/push WorkingDirectory=%h/.push Restart=on-failure RestartSec=10 Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +Environment=PUSH_HOME=%h/.push EnvironmentFile=-%h/.config/push/env [Install] diff --git a/src/agent.rs b/src/agent.rs index a6306b2..083d223 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -80,6 +80,7 @@ impl Runner { }), AgentBackend::Codex => Runner::Codex(codex::Runner { bin: cfg.agent_bin(backend).to_string(), + cache_dir: cfg.paths.cache.clone(), }), AgentBackend::Pi => Runner::Pi(pi::Runner { bin: cfg.agent_bin(backend).to_string(), diff --git a/src/assistant.rs b/src/assistant.rs index 239b20c..3d37f32 100644 --- a/src/assistant.rs +++ b/src/assistant.rs @@ -12,6 +12,7 @@ use crate::config::{ validate_inline_slack_token_location, validate_inline_token_location, validate_inline_voice_key_location, }; +use crate::paths::PushPaths; use crate::util::expand_home; const SOUL: &str = r#"# SOUL @@ -84,6 +85,7 @@ pub struct InitResult { } pub fn init(requested_path: &str, config_path: &str) -> Result { + let paths = PushPaths::discover()?; let requested = expand_home(requested_path); if requested.starts_with('~') { bail!("cannot expand assistant path {requested_path:?}; set HOME or use an absolute path"); @@ -94,7 +96,7 @@ pub fn init(requested_path: &str, config_path: &str) -> Result { bail!("cannot expand config path {config_path:?}; set HOME or use an absolute path"); } let config_path = absolute_path(Path::new(&expanded_config)).context("resolve config path")?; - let existing_config = inspect_config(&config_path, &target)?; + let existing_config = inspect_config(&config_path, &target, &paths)?; prepare_target(&target, &config_path)?; let root = fs::canonicalize(&target) @@ -102,7 +104,7 @@ pub fn init(requested_path: &str, config_path: &str) -> Result { scaffold(&root)?; let git_initialized = initialize_git(&root)?; persist_root(&config_path, &root, existing_config)?; - if inspect_config(&config_path, &root)? != ConfigState::MatchingRoot { + if inspect_config(&config_path, &root, &paths)? != ConfigState::MatchingRoot { bail!( "assistant validation failed: {} did not persist assistant_root", config_path.display() @@ -124,11 +126,11 @@ enum ConfigState { MatchingRoot, } -fn inspect_config(config_path: &Path, target: &Path) -> Result { +fn inspect_config(config_path: &Path, target: &Path, paths: &PushPaths) -> Result { let raw = match fs::read_to_string(config_path) { Ok(raw) => raw, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - validate_runtime_boundary(None, target)?; + validate_runtime_boundary(None, target, paths)?; return Ok(ConfigState::MissingFile); } Err(error) => { @@ -139,7 +141,7 @@ fn inspect_config(config_path: &Path, target: &Path) -> Result { toml::from_str(&raw).with_context(|| format!("parse config {}", config_path.display()))?; let table = value.as_table().context("config must be a TOML table")?; validate_config_secrets(config_path, target, table)?; - validate_runtime_boundary(Some(table), target)?; + validate_runtime_boundary(Some(table), target, paths)?; if table.contains_key("assistant_dir") || table.contains_key("jobs_dir") { bail!( "{} uses legacy assistant_dir or jobs_dir settings. Move SOUL.md, context, and jobs under one assistant directory, replace those settings with assistant_root, then rerun push init.", @@ -205,18 +207,38 @@ fn validate_config_secrets(config_path: &Path, target: &Path, config: &toml::Tab Ok(()) } -fn validate_runtime_boundary(config: Option<&toml::Table>, target: &Path) -> Result<()> { +fn validate_runtime_boundary( + config: Option<&toml::Table>, + target: &Path, + defaults: &PushPaths, +) -> Result<()> { let assistant = resolve_existing_or_lexical(target)?; - let jobs_run = configured_runtime_path(config, "jobs_run_dir", "~/.push/run")?; + let runtime = defaults.clone().with_overrides( + configured_override(config, "state_path")?, + configured_override(config, "database_path")?, + configured_override(config, "audit_log_path")?, + configured_override(config, "jobs_run_dir")?, + )?; + let push_home = resolve_existing_or_lexical(&runtime.root)?; + if assistant.starts_with(&push_home) || push_home.starts_with(&assistant) { + bail!( + "assistant_root {} must stay outside Push home {}; choose a separate assistant repository or set PUSH_HOME to a separate runtime directory", + assistant.display(), + push_home.display() + ); + } + let jobs_run = resolve_existing_or_lexical(&runtime.jobs_run)?; if assistant.starts_with(&jobs_run) || jobs_run.starts_with(&assistant) { bail!("jobs_run_dir must stay outside assistant_root; choose a separate assistant path or update jobs_run_dir"); } - for (key, default) in [ - ("state_path", "~/.push/state.json"), - ("database_path", "~/.push/push.db"), - ("audit_log_path", "~/.push/audit.jsonl"), + for (key, path) in [ + ("state_path", runtime.state.as_path()), + ("database_path", runtime.database.as_path()), + ("audit_log_path", runtime.audit.as_path()), + ("Slack inbox", runtime.inbox.as_path()), + ("cache directory", runtime.cache.as_path()), ] { - let runtime = configured_runtime_path(config, key, default)?; + let runtime = resolve_existing_or_lexical(path)?; if runtime.starts_with(&assistant) { bail!("{key} must stay outside assistant_root; choose a separate assistant path or update {key}"); } @@ -224,26 +246,23 @@ fn validate_runtime_boundary(config: Option<&toml::Table>, target: &Path) -> Res Ok(()) } -fn configured_runtime_path( - config: Option<&toml::Table>, - key: &str, - default: &str, -) -> Result { - let value = match config.and_then(|table| table.get(key)) { - Some(value) => value - .as_str() - .with_context(|| format!("{key} must be a string"))?, - None => default, - }; - let expanded = expand_home(value); - if expanded.starts_with('~') { - bail!("cannot expand configured {key} {value:?}"); - } - let path = Path::new(&expanded); - if !path.is_absolute() { - bail!("{key} must be an absolute path or start with ~"); - } - resolve_existing_or_lexical(path) +fn configured_override<'a>(config: Option<&'a toml::Table>, key: &str) -> Result> { + config + .and_then(|table| table.get(key)) + .map(|value| { + let value = value + .as_str() + .with_context(|| format!("{key} must be a string"))?; + let expanded = expand_home(value); + if expanded.starts_with('~') { + bail!("cannot expand configured {key} {value:?}"); + } + if !Path::new(&expanded).is_absolute() { + bail!("{key} must be an absolute path or start with ~"); + } + Ok(value) + }) + .transpose() } fn configured_root(config_path: &Path, configured: &str) -> Result { diff --git a/src/audit.rs b/src/audit.rs index 4f7139c..122c921 100644 --- a/src/audit.rs +++ b/src/audit.rs @@ -1,6 +1,6 @@ //! Local JSONL audit log for production debugging. -use std::path::Path; +use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -12,7 +12,7 @@ use crate::config::AgentBackend; #[derive(Clone)] pub struct AuditLog { - path: String, + path: PathBuf, include_content: bool, channel: String, lock: Arc>, @@ -63,18 +63,18 @@ pub struct AuditContent { impl AuditLog { #[cfg_attr(not(test), allow(dead_code))] - pub fn new(path: String, include_content: bool, channel: &str) -> Self { + pub fn new(path: impl Into, include_content: bool, channel: &str) -> Self { Self::with_lock(path, include_content, channel, Arc::new(Mutex::new(()))) } pub(crate) fn with_lock( - path: String, + path: impl Into, include_content: bool, channel: &str, lock: Arc>, ) -> Self { Self { - path, + path: path.into(), include_content, channel: channel.to_string(), lock, @@ -83,7 +83,7 @@ impl AuditLog { pub fn record(&self, event: AuditEvent) -> Result<()> { let _guard = self.lock.lock().unwrap(); - let path = Path::new(&self.path); + let path = self.path.as_path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("create audit log directory {}", parent.display()))?; @@ -97,9 +97,9 @@ impl AuditLog { } let mut file = options .open(path) - .with_context(|| format!("open audit log {}", self.path))?; + .with_context(|| format!("open audit log {}", self.path.display()))?; crate::util::restrict_permissions(path, false) - .with_context(|| format!("restrict audit log permissions {}", self.path))?; + .with_context(|| format!("restrict audit log permissions {}", self.path.display()))?; serde_json::to_writer(&mut file, &event).context("write audit event")?; use std::io::Write; writeln!(file).context("finish audit event")?; diff --git a/src/channel.rs b/src/channel.rs index 8106470..3306ffe 100644 --- a/src/channel.rs +++ b/src/channel.rs @@ -172,7 +172,7 @@ impl Channel { cfg.slack_bot_token() .ok_or_else(|| anyhow::anyhow!("Slack bot token is not configured"))?, cfg.slack_allow_user_ids.clone(), - &cfg.state_path, + &cfg.paths.inbox, )?)), } } diff --git a/src/codex.rs b/src/codex.rs index d2f1539..869a512 100644 --- a/src/codex.rs +++ b/src/codex.rs @@ -15,6 +15,7 @@ use crate::agent::{final_reply, Request, RunError, RunOutput}; /// Runner invokes `codex exec` in non-interactive mode. pub struct Runner { pub bin: String, + pub cache_dir: PathBuf, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -39,9 +40,10 @@ struct OutputFile { } impl OutputFile { - fn create() -> std::io::Result { - let path = - std::env::temp_dir().join(format!("push-codex-last-message-{}.txt", Uuid::new_v4())); + fn create(cache_dir: &Path) -> std::io::Result { + std::fs::create_dir_all(cache_dir)?; + crate::util::restrict_permissions(cache_dir, true)?; + let path = cache_dir.join(format!("codex-last-message-{}.txt", Uuid::new_v4())); OpenOptions::new() .write(true) .create_new(true) @@ -85,7 +87,7 @@ impl Runner { timeout: Duration, mode: RunMode, ) -> Result { - let output_file = OutputFile::create() + let output_file = OutputFile::create(&self.cache_dir) .map_err(|error| RunError::Failed(format!("prepare Codex output: {error}")))?; let out_path = output_file.path.as_path(); let attempt = crate::agent::output_with_retry(|| { @@ -286,6 +288,20 @@ mod tests { assert_eq!(last_agent_message_from_jsonl(s), Some("two".to_string())); } + #[test] + fn final_reply_file_uses_the_configured_cache_directory() { + let cache = temp_path("codex-output-cache"); + let output = OutputFile::create(&cache).unwrap(); + + assert_eq!(output.path.parent(), Some(cache.as_path())); + assert!(output.path.is_file()); + let path = output.path.clone(); + drop(output); + assert!(!path.exists()); + + let _ = std::fs::remove_dir(cache); + } + #[tokio::test] async fn satisfies_runner_contract() { assert_runner_contract(RunnerContract { @@ -516,7 +532,10 @@ sleep 2 } fn runner(bin: String) -> Runner { - Runner { bin } + Runner { + bin, + cache_dir: temp_dir("codex-cache"), + } } fn request(work_dir: &str) -> Request<'_> { diff --git a/src/config.rs b/src/config.rs index 9c8637b..0d075da 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,6 +7,7 @@ use std::time::Duration; use anyhow::{bail, Context, Result}; use serde::Deserialize; +use crate::paths::PushPaths; use crate::util::expand_home; pub const TELEGRAM_BOT_TOKEN_ENV: &str = "TELEGRAM_BOT_TOKEN"; @@ -85,16 +86,16 @@ pub struct Config { pub jobs_agent: Option, #[serde(default = "default_jobs_max_timeout")] pub jobs_max_timeout: String, - #[serde(default = "default_jobs_run_dir")] - pub jobs_run_dir: String, + #[serde(default, rename = "jobs_run_dir")] + pub(crate) jobs_run_dir_override: Option, #[serde(default = "default_jobs_max_workers")] pub jobs_max_workers: usize, - #[serde(default = "default_state_path")] - pub state_path: String, - #[serde(default = "default_audit_log_path")] - pub audit_log_path: String, - #[serde(default = "default_database_path")] - pub database_path: String, + #[serde(default, rename = "state_path")] + pub(crate) state_path_override: Option, + #[serde(default, rename = "audit_log_path")] + pub(crate) audit_log_path_override: Option, + #[serde(default, rename = "database_path")] + pub(crate) database_path_override: Option, #[serde(default)] pub audit_log_content: bool, /// Derived from `assistant_root`. Parsed only for legacy migration. @@ -103,6 +104,9 @@ pub struct Config { /// Canonical path of the loaded config file. Set by `load`, never parsed. #[serde(skip)] pub config_path: String, + /// Authoritative resolved locations for all Push-owned runtime data. + #[serde(skip)] + pub paths: PushPaths, /// Test-only command injection point. Never parsed from user configuration. #[cfg(test)] #[serde(skip)] @@ -112,6 +116,10 @@ pub struct Config { impl Config { /// Load, expand `~` in path fields, and validate the config at `path`. pub fn load(path: &str) -> Result { + Self::load_with_paths(path, PushPaths::discover()?) + } + + pub(crate) fn load_with_paths(path: &str, default_paths: PushPaths) -> Result { let expanded_path = expand_home(path); let raw = std::fs::read_to_string(&expanded_path) .with_context(|| format!("read config {expanded_path}"))?; @@ -238,9 +246,12 @@ impl Config { let config_path = std::fs::canonicalize(&expanded_path) .with_context(|| format!("resolve config {expanded_path}"))?; c.db_path = expand_home(&c.db_path); - c.state_path = expand_home(&c.state_path); - c.audit_log_path = expand_home(&c.audit_log_path); - c.database_path = expand_home(&c.database_path); + c.paths = default_paths.with_overrides( + c.state_path_override.as_deref(), + c.database_path_override.as_deref(), + c.audit_log_path_override.as_deref(), + c.jobs_run_dir_override.as_deref(), + )?; let assistant_root = if has_assistant_root { resolve_assistant_root(&c.assistant_root, &config_path)? } else { @@ -277,10 +288,7 @@ impl Config { c.assistant_root = assistant_root.to_string_lossy().to_string(); c.assistant_dir = c.assistant_root.clone(); c.jobs_dir = assistant_root.join("jobs").to_string_lossy().to_string(); - c.jobs_run_dir = expand_home(&c.jobs_run_dir); - if has_assistant_root { - validate_runtime_outside_assistant(&c)?; - } + validate_runtime_outside_assistant(&c)?; c.validate()?; c.config_path = config_path.to_string_lossy().to_string(); Ok(c) @@ -413,16 +421,19 @@ impl Config { pub fn validate_job_workdir(&self, workdir: &Path) -> Result<()> { let workdir = resolved_absolute("job workdir", workdir)?; let protected_paths = [ - ("jobs_run_dir", self.jobs_run_dir.as_str()), - ("state_path", self.state_path.as_str()), - ("database_path", self.database_path.as_str()), - ("audit_log_path", self.audit_log_path.as_str()), + ("Push home", self.paths.root.as_path()), + ("jobs_run_dir", self.paths.jobs_run.as_path()), + ("state_path", self.paths.state.as_path()), + ("database_path", self.paths.database.as_path()), + ("audit_log_path", self.paths.audit.as_path()), + ("Slack inbox", self.paths.inbox.as_path()), + ("cache directory", self.paths.cache.as_path()), ]; for (label, protected) in protected_paths .into_iter() - .filter(|(_, protected)| !protected.is_empty()) + .filter(|(_, protected)| !protected.as_os_str().is_empty()) { - let protected = resolved_absolute(label, Path::new(protected))?; + let protected = resolved_absolute(label, protected)?; if paths_overlap(&workdir, &protected) { bail!( "job workdir {} overlaps Push-owned {label} {}", @@ -557,7 +568,7 @@ impl Config { if self.jobs_max_timeout_dur()?.is_zero() { bail!("jobs_max_timeout must be positive"); } - if self.jobs_dir.trim().is_empty() || self.jobs_run_dir.trim().is_empty() { + if self.jobs_dir.trim().is_empty() || self.paths.jobs_run.as_os_str().is_empty() { bail!("jobs_dir and jobs_run_dir cannot be empty"); } if self.jobs_max_workers == 0 { @@ -584,16 +595,26 @@ impl Config { fn validate_runtime_outside_assistant(cfg: &Config) -> Result<()> { let assistant = resolved_absolute("assistant_root", Path::new(&cfg.assistant_root))?; - let jobs_run = resolved_absolute("jobs_run_dir", Path::new(&cfg.jobs_run_dir))?; + let push_home = resolved_absolute("PUSH_HOME", &cfg.paths.root)?; + if paths_overlap(&assistant, &push_home) { + bail!( + "assistant_root {} must stay outside Push home {}; choose a separate assistant repository or set PUSH_HOME to a separate runtime directory", + assistant.display(), + push_home.display() + ); + } + let jobs_run = resolved_absolute("jobs_run_dir", &cfg.paths.jobs_run)?; if paths_overlap(&assistant, &jobs_run) { bail!("jobs_run_dir must stay outside assistant_root"); } for (label, value) in [ - ("state_path", cfg.state_path.as_str()), - ("database_path", cfg.database_path.as_str()), - ("audit_log_path", cfg.audit_log_path.as_str()), + ("state_path", cfg.paths.state.as_path()), + ("database_path", cfg.paths.database.as_path()), + ("audit_log_path", cfg.paths.audit.as_path()), + ("Slack inbox", cfg.paths.inbox.as_path()), + ("cache directory", cfg.paths.cache.as_path()), ] { - let path = resolved_absolute(label, Path::new(value))?; + let path = resolved_absolute(label, value)?; if path.starts_with(&assistant) { bail!("{label} must stay outside assistant_root"); } @@ -868,21 +889,9 @@ fn default_jobs_dir() -> String { fn default_jobs_max_timeout() -> String { "30m".to_string() } -fn default_jobs_run_dir() -> String { - "~/.push/run".to_string() -} fn default_jobs_max_workers() -> usize { 2 } -fn default_state_path() -> String { - "~/.push/state.json".to_string() -} -fn default_audit_log_path() -> String { - "~/.push/audit.jsonl".to_string() -} -fn default_database_path() -> String { - "~/.push/push.db".to_string() -} fn default_assistant_dir() -> String { "~/.push".to_string() } @@ -916,13 +925,14 @@ mod tests { jobs_dir: root.join("jobs").to_string_lossy().to_string(), jobs_agent: None, jobs_max_timeout: "30m".to_string(), - jobs_run_dir: root.join("run").to_string_lossy().to_string(), + jobs_run_dir_override: None, jobs_max_workers: 2, - state_path: root.join("state.json").to_string_lossy().to_string(), - audit_log_path: root.join("audit.jsonl").to_string_lossy().to_string(), - database_path: root.join("push.db").to_string_lossy().to_string(), + state_path_override: None, + audit_log_path_override: None, + database_path_override: None, audit_log_content: false, config_path: String::new(), + paths: PushPaths::from_root(root.join("runtime")).unwrap(), agent_commands: AgentCommands::default(), assistant_dir: root.to_string_lossy().to_string(), } diff --git a/src/doctor.rs b/src/doctor.rs index 4d4461f..c8589b2 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -56,13 +56,13 @@ fn run_checks(cfg: &config::Config) -> CheckReport { check_parent_dir( "state directory", "state_path", - &cfg.state_path, + &cfg.paths.state, &mut checks, ); check_parent_dir( "audit log directory", "audit_log_path", - &cfg.audit_log_path, + &cfg.paths.audit, &mut checks, ); check_history_database(cfg, &mut checks); @@ -228,8 +228,8 @@ fn check_config(cfg: &config::Config, checks: &mut Vec) { } /// Checks that the parent directory of a configured file path is writable. -fn check_parent_dir(name: &str, field: &str, path: &str, checks: &mut Vec) { - if let Some(parent) = Path::new(path).parent() { +fn check_parent_dir(name: &str, field: &str, path: &Path, checks: &mut Vec) { + if let Some(parent) = path.parent() { check_writable_dir(name, field, parent, checks); } else { checks.push(Check::pass( @@ -256,16 +256,16 @@ fn check_writable_dir(name: &str, field: &str, dir: &Path, checks: &mut Vec) { - match history::History::open(&cfg.database_path) { + match history::History::open(&cfg.paths.database) { Ok(_) => checks.push(Check::pass( "conversation database", - format!("{} is ready", cfg.database_path), + format!("{} is ready", cfg.paths.database.display()), )), Err(error) => checks.push(Check::fail( "conversation database", format!( "cannot open {}: {error}. Choose a writable database_path and repair or remove an invalid database.", - cfg.database_path + cfg.paths.database.display() ), )), } @@ -768,15 +768,9 @@ claude_tools = [] let state_path = temp_path("state-dir").join("state.json"); let mut cfg = test_config(); cfg.db_path = db_path.to_string_lossy().to_string(); - cfg.state_path = state_path.to_string_lossy().to_string(); - cfg.audit_log_path = state_path - .with_extension("audit.jsonl") - .to_string_lossy() - .to_string(); - cfg.database_path = state_path - .with_extension("push.db") - .to_string_lossy() - .to_string(); + cfg.paths.state = state_path.clone(); + cfg.paths.audit = state_path.with_extension("audit.jsonl"); + cfg.paths.database = state_path.with_extension("push.db"); let report = run_checks(&cfg); assert!(report @@ -798,8 +792,8 @@ claude_tools = [] let _ = std::fs::remove_file(db_path); let _ = std::fs::remove_file(state_path); - let _ = std::fs::remove_file(cfg.audit_log_path); - let _ = std::fs::remove_file(cfg.database_path); + let _ = std::fs::remove_file(cfg.paths.audit); + let _ = std::fs::remove_file(cfg.paths.database); } #[test] diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index b008e5a..3f7a7f9 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -116,10 +116,15 @@ struct WorkerState { impl GatewayGroup { pub fn new(cfg: Config) -> Result { let enabled = cfg.enabled_channel_kinds()?; - let store = Arc::new(Mutex::new(Store::open(&cfg.state_path)?)); - let history = Arc::new(Mutex::new(History::open(&cfg.database_path).with_context( - || format!("open canonical history database {}", cfg.database_path), - )?)); + let store = Arc::new(Mutex::new(Store::open(&cfg.paths.state)?)); + let history = Arc::new(Mutex::new( + History::open(&cfg.paths.database).with_context(|| { + format!( + "open canonical history database {}", + cfg.paths.database.display() + ) + })?, + )); let runners = Arc::new(runners(&cfg)); let audit_lock = Arc::new(Mutex::new(())); let mut gateways = Vec::with_capacity(enabled.len()); @@ -328,10 +333,15 @@ where impl Gateway { #[cfg_attr(not(test), allow(dead_code))] pub fn new(cfg: Config) -> Result { - let store = Arc::new(Mutex::new(Store::open(&cfg.state_path)?)); - let history = Arc::new(Mutex::new(History::open(&cfg.database_path).with_context( - || format!("open canonical history database {}", cfg.database_path), - )?)); + let store = Arc::new(Mutex::new(Store::open(&cfg.paths.state)?)); + let history = Arc::new(Mutex::new( + History::open(&cfg.paths.database).with_context(|| { + format!( + "open canonical history database {}", + cfg.paths.database.display() + ) + })?, + )); let runners = Arc::new(runners(&cfg)); let kind = cfg .enabled_channel_kinds()? @@ -352,7 +362,7 @@ impl Gateway { let ack = Arc::new(Mutex::new(AckState::default())); let channel = Channel::new_for(&cfg, kind)?; let audit = Arc::new(AuditLog::with_lock( - cfg.audit_log_path.clone(), + cfg.paths.audit.clone(), cfg.audit_log_content, channel.id(), audit_lock, diff --git a/src/gateway/tests.rs b/src/gateway/tests.rs index 6552b7d..92bc1e4 100644 --- a/src/gateway/tests.rs +++ b/src/gateway/tests.rs @@ -1202,7 +1202,7 @@ async fn pending_outbound_is_delivered_after_restart_without_backend_rerun() { sessions_dir.to_str().unwrap(), assistant_dir.to_str().unwrap(), ); - let mut history = History::open(&config.database_path).unwrap(); + let mut history = History::open(&config.paths.database).unwrap(); let inbound_id = history .record_inbound( "imessage", @@ -2546,7 +2546,7 @@ async fn missing_primary_disables_new_schedules_without_stopping_gateway() { .await .unwrap(); - let rows = crate::jobs::Ledger::open(&cfg.database_path) + let rows = crate::jobs::Ledger::open(&cfg.paths.database) .unwrap() .runs(Some("disabled")) .unwrap(); @@ -2786,13 +2786,23 @@ fn test_config(state_path: &str, _sessions_dir: &str, assistant_dir: &str) -> Co jobs_dir: format!("{state_path}.jobs"), jobs_agent: None, jobs_max_timeout: "30m".to_string(), - jobs_run_dir: format!("{state_path}.run"), + jobs_run_dir_override: None, jobs_max_workers: 2, - state_path: state_path.to_string(), - audit_log_path: format!("{state_path}.audit.jsonl"), - database_path: format!("{state_path}.db"), + state_path_override: None, + audit_log_path_override: None, + database_path_override: None, audit_log_content: false, config_path: String::new(), + paths: crate::paths::PushPaths { + root: PathBuf::from(format!("{state_path}.home")), + config: PathBuf::from(format!("{state_path}.config.toml")), + database: PathBuf::from(format!("{state_path}.db")), + state: PathBuf::from(state_path), + audit: PathBuf::from(format!("{state_path}.audit.jsonl")), + jobs_run: PathBuf::from(format!("{state_path}.run")), + inbox: PathBuf::from(format!("{state_path}.slack-inbox.db")), + cache: PathBuf::from(format!("{state_path}.cache")), + }, agent_commands: crate::config::AgentCommands::default(), assistant_dir: assistant_dir.to_string(), } diff --git a/src/history.rs b/src/history.rs index 696ebd6..dec8a8e 100644 --- a/src/history.rs +++ b/src/history.rs @@ -1,6 +1,6 @@ //! Canonical SQLite conversation history owned by the gateway. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::{bail, Context, Result}; @@ -93,8 +93,8 @@ pub struct History { } impl History { - pub fn open(path: &str) -> Result { - let path = PathBuf::from(path); + pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref().to_path_buf(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("create database directory {}", parent.display()))?; diff --git a/src/jobs.rs b/src/jobs.rs index 105098d..c9cde17 100644 --- a/src/jobs.rs +++ b/src/jobs.rs @@ -603,8 +603,8 @@ pub struct JobLock { } impl JobLock { - fn try_acquire(run_dir: &str, job_name: &str) -> Result> { - let lock_dir = Path::new(run_dir).join("locks"); + fn try_acquire(run_dir: &Path, job_name: &str) -> Result> { + let lock_dir = run_dir.join("locks"); std::fs::create_dir_all(&lock_dir) .with_context(|| format!("create job lock directory {}", lock_dir.display()))?; restrict_permissions(&lock_dir, true) @@ -743,7 +743,8 @@ impl DeliveryAttempt { } impl Ledger { - pub fn open(database_path: &str) -> Result { + pub fn open(database_path: impl AsRef) -> Result { + let database_path = database_path.as_ref(); drop(History::open(database_path)?); let conn = Connection::open(database_path)?; conn.busy_timeout(Duration::from_secs(5))?; @@ -752,7 +753,7 @@ impl Ledger { pub fn start_manual(&mut self, cfg: &Config, job: &Job) -> Result { let now = now_ms(); - let Some(lock) = JobLock::try_acquire(&cfg.jobs_run_dir, &job.name)? else { + let Some(lock) = JobLock::try_acquire(&cfg.paths.jobs_run, &job.name)? else { let run_id = Uuid::new_v4().to_string(); self.insert_skipped( &run_id, @@ -982,7 +983,7 @@ impl Ledger { queued: &QueuedRun, now: i64, ) -> Result> { - let Some(lock) = JobLock::try_acquire(&cfg.jobs_run_dir, &queued.job_name)? else { + let Some(lock) = JobLock::try_acquire(&cfg.paths.jobs_run, &queued.job_name)? else { self.conn.execute( "UPDATE job_runs SET state = 'skipped_overlap', finished_at_ms = ?2, error = 'another local executor holds the job lock', delivery_state = 'pending' @@ -1086,7 +1087,7 @@ impl Ledger { .collect::>>()?; drop(statement); for name in names { - let Some(_lock) = JobLock::try_acquire(&cfg.jobs_run_dir, &name)? else { + let Some(_lock) = JobLock::try_acquire(&cfg.paths.jobs_run, &name)? else { continue; }; self.conn.execute( @@ -1371,7 +1372,7 @@ impl Scheduler { // tick starts from a freshly opened ledger. let mut ledger = match self.ledger.take() { Some(ledger) => ledger, - None => Ledger::open(&self.cfg.database_path)?, + None => Ledger::open(&self.cfg.paths.database)?, }; ledger.recover_stale_runs(&self.cfg, now)?; @@ -1464,7 +1465,7 @@ impl Scheduler { &self.delivery_owner, delivery_slots, )? { - let database_path = self.cfg.database_path.clone(); + let database_path = self.cfg.paths.database.clone(); let owner = self.delivery_owner.clone(); let deliver = deliver.clone(); self.delivery_workers.spawn(async move { @@ -1524,7 +1525,7 @@ impl Scheduler { fn recover_interrupted_work(&mut self) { let mut ledger = match self.ledger.take() { Some(ledger) => ledger, - None => match Ledger::open(&self.cfg.database_path) { + None => match Ledger::open(&self.cfg.paths.database) { Ok(ledger) => ledger, Err(error) => { tracing::error!("open job ledger during scheduler shutdown: {error:#}"); @@ -1587,7 +1588,7 @@ fn log_shutdown_result( } async fn run_delivery( - database_path: String, + database_path: PathBuf, owner: String, row: DeliveryRun, attempted_at_ms: i64, @@ -1611,7 +1612,7 @@ where } async fn run_delivery_with_timeout( - database_path: String, + database_path: PathBuf, owner: String, row: DeliveryRun, attempted_at_ms: i64, @@ -1693,7 +1694,7 @@ fn elapsed_ms_since(start_ms: i64, started: Instant) -> i64 { } async fn run_scheduled(cfg: Config, queued: QueuedRun) -> Result<()> { - let mut ledger = Ledger::open(&cfg.database_path)?; + let mut ledger = Ledger::open(&cfg.paths.database)?; let Some((run_id, job, _lock)) = ledger.claim_scheduled(&cfg, &queued, now_ms())? else { return Ok(()); }; @@ -1755,7 +1756,7 @@ fn format_delivery(row: &DeliveryRun) -> String { } pub async fn run_manual(cfg: &Config, job: Job) -> Result<(String, String)> { - let mut ledger = Ledger::open(&cfg.database_path)?; + let mut ledger = Ledger::open(&cfg.paths.database)?; let (run_id, _lock, job) = match ledger.start_manual(cfg, &job)? { StartOutcome::Claimed { run_id, lock, job } => (run_id, lock, job), StartOutcome::Skipped { run_id } => { @@ -2119,7 +2120,7 @@ mod tests { let ready = PathBuf::from(std::env::var("PUSH_TEST_CLAIM_READY").unwrap()); let cfg = cfg(Path::new(&jobs_dir), &database, &run_dir); let job = Catalog::load_named(&cfg, "cli-live").unwrap(); - let mut ledger = Ledger::open(&cfg.database_path).unwrap(); + let mut ledger = Ledger::open(&cfg.paths.database).unwrap(); let StartOutcome::Claimed { lock: _lock, .. } = ledger.start_manual(&cfg, &job).unwrap() else { panic!("helper manual run should claim"); @@ -2135,8 +2136,8 @@ mod tests { temp_dir("jobs-assistant").to_str().unwrap(), ); cfg.jobs_dir = jobs_dir.to_string_lossy().to_string(); - cfg.database_path = database.to_string_lossy().to_string(); - cfg.jobs_run_dir = run_dir.to_string_lossy().to_string(); + cfg.paths.database = database.to_path_buf(); + cfg.paths.jobs_run = run_dir.to_path_buf(); cfg } @@ -2526,7 +2527,7 @@ mod tests { write_job(&jobs_dir, "claim", &scheduled_job(&workdir, true)); let cfg = cfg(&jobs_dir, &database, &run_dir); let job = Catalog::load_named(&cfg, "claim").unwrap(); - let mut first = Ledger::open(&cfg.database_path).unwrap(); + let mut first = Ledger::open(&cfg.paths.database).unwrap(); let id = first .enqueue_scheduled(&job, &job.triggers[0], 60_000, 60_000, "telegram", "7") .unwrap(); @@ -2538,7 +2539,7 @@ mod tests { [&id], ) .unwrap(); - let mut second = Ledger::open(&cfg.database_path).unwrap(); + let mut second = Ledger::open(&cfg.paths.database).unwrap(); let claimed = first.claim_due_deliveries(60_000, "first", 1).unwrap(); assert_eq!(claimed.len(), 1); @@ -2584,7 +2585,7 @@ mod tests { write_job(&jobs_dir, "delayed-claim", &scheduled_job(&workdir, true)); let cfg = cfg(&jobs_dir, &database, &run_dir); let job = Catalog::load_named(&cfg, "delayed-claim").unwrap(); - let mut first = Ledger::open(&cfg.database_path).unwrap(); + let mut first = Ledger::open(&cfg.paths.database).unwrap(); let id = first .enqueue_scheduled(&job, &job.triggers[0], 60_000, 60_000, "telegram", "7") .unwrap(); @@ -2609,7 +2610,7 @@ mod tests { 1 ); - let mut second = Ledger::open(&cfg.database_path).unwrap(); + let mut second = Ledger::open(&cfg.paths.database).unwrap(); assert!(second .claim_due_deliveries(60_000 + DELIVERY_CLAIM_LEASE_MS, "second-worker", 1,) .unwrap() @@ -2625,7 +2626,7 @@ mod tests { write_job(&jobs_dir, "checkpoint", &scheduled_job(&workdir, true)); let cfg = cfg(&jobs_dir, &database, &run_dir); let job = Catalog::load_named(&cfg, "checkpoint").unwrap(); - let mut ledger = Ledger::open(&cfg.database_path).unwrap(); + let mut ledger = Ledger::open(&cfg.paths.database).unwrap(); let id = ledger .enqueue_scheduled(&job, &job.triggers[0], 60_000, 60_000, "telegram", "7") .unwrap(); @@ -2647,7 +2648,7 @@ mod tests { let release = Arc::new(tokio::sync::Notify::new()); let captured_checkpoint = checkpoint_sent.clone(); let captured_release = release.clone(); - let database_path = cfg.database_path.clone(); + let database_path = cfg.paths.database.clone(); let claimed_at = Instant::now(); let task = tokio::spawn(async move { run_delivery_with_timeout( @@ -2672,7 +2673,7 @@ mod tests { }); checkpoint_sent.notified().await; - let saved_chunk = Ledger::open(&cfg.database_path) + let saved_chunk = Ledger::open(&cfg.paths.database) .unwrap() .conn .query_row( @@ -2685,7 +2686,7 @@ mod tests { release.notify_one(); task.await.unwrap().unwrap(); - let ledger = Ledger::open(&cfg.database_path).unwrap(); + let ledger = Ledger::open(&cfg.paths.database).unwrap(); let final_state = ledger .conn .query_row( @@ -2706,7 +2707,7 @@ mod tests { write_job(&jobs_dir, "attempt-timeout", &scheduled_job(&workdir, true)); let cfg = cfg(&jobs_dir, &database, &run_dir); let job = Catalog::load_named(&cfg, "attempt-timeout").unwrap(); - let mut ledger = Ledger::open(&cfg.database_path).unwrap(); + let mut ledger = Ledger::open(&cfg.paths.database).unwrap(); let id = ledger .enqueue_scheduled(&job, &job.triggers[0], 60_000, 60_000, "telegram", "7") .unwrap(); @@ -2729,7 +2730,7 @@ mod tests { let captured = invoked.clone(); run_delivery_with_timeout( - cfg.database_path.clone(), + cfg.paths.database.clone(), "timeout-worker".to_string(), row, 60_000, @@ -2743,7 +2744,7 @@ mod tests { .await .unwrap(); - let ledger = Ledger::open(&cfg.database_path).unwrap(); + let ledger = Ledger::open(&cfg.paths.database).unwrap(); let state = ledger .conn .query_row( @@ -2777,7 +2778,7 @@ mod tests { write_job(&jobs_dir, "backoff", &scheduled_job(&workdir, true)); let cfg = cfg(&jobs_dir, &database, &run_dir); let job = Catalog::load_named(&cfg, "backoff").unwrap(); - let mut ledger = Ledger::open(&cfg.database_path).unwrap(); + let mut ledger = Ledger::open(&cfg.paths.database).unwrap(); let id = ledger .enqueue_scheduled(&job, &job.triggers[0], 60_000, 60_000, "telegram", "7") .unwrap(); @@ -2796,7 +2797,7 @@ mod tests { drop(ledger); run_delivery( - cfg.database_path.clone(), + cfg.paths.database.clone(), "slow-worker".to_string(), row, 60_000, @@ -2809,7 +2810,7 @@ mod tests { .await .unwrap(); - let mut ledger = Ledger::open(&cfg.database_path).unwrap(); + let mut ledger = Ledger::open(&cfg.paths.database).unwrap(); let completed_at_ms = ledger .conn .query_row( @@ -2841,7 +2842,7 @@ mod tests { write_job(&jobs_dir, "slow", &scheduled_job(&workdir, false)); let cfg = cfg(&jobs_dir, &database, &run_dir); let job = Catalog::load_named(&cfg, "slow").unwrap(); - let mut ledger = Ledger::open(&cfg.database_path).unwrap(); + let mut ledger = Ledger::open(&cfg.paths.database).unwrap(); let id = ledger .enqueue_scheduled(&job, &job.triggers[0], 60_000, 60_000, "telegram", "7") .unwrap(); @@ -2895,7 +2896,7 @@ mod tests { let mut cfg = cfg(&jobs_dir, &database, &run_dir); cfg.agent_commands.codex = slow.bin(); let catalog = Catalog::load(&cfg).unwrap(); - let mut ledger = Ledger::open(&cfg.database_path).unwrap(); + let mut ledger = Ledger::open(&cfg.paths.database).unwrap(); let execution_id = ledger .enqueue_scheduled( &catalog.jobs["execution"], @@ -2943,7 +2944,7 @@ mod tests { .unwrap(); checkpoint_saved.notified().await; for _ in 0..100 { - if Ledger::open(&cfg.database_path) + if Ledger::open(&cfg.paths.database) .unwrap() .state(&execution_id) == "running" @@ -2953,7 +2954,7 @@ mod tests { tokio::task::yield_now().await; } assert_eq!( - Ledger::open(&cfg.database_path) + Ledger::open(&cfg.paths.database) .unwrap() .state(&execution_id), "running" @@ -2968,7 +2969,7 @@ mod tests { .expect("scheduler shutdown must finish within its grace plus cleanup"); assert!(started.elapsed() < Duration::from_millis(500)); - let mut ledger = Ledger::open(&cfg.database_path).unwrap(); + let mut ledger = Ledger::open(&cfg.paths.database).unwrap(); let execution = ledger.runs(Some("execution")).unwrap().remove(0); assert_eq!(execution.state, "failed"); assert_eq!(execution.delivery_state, "pending"); @@ -3165,7 +3166,7 @@ printf '%s\n' '{{"type":"thread.started","thread_id":"scheduled"}}' .unwrap(); scheduler.shutdown().await; - let rows = Ledger::open(&cfg.database_path) + let rows = Ledger::open(&cfg.paths.database) .unwrap() .runs(Some("scheduled")) .unwrap(); @@ -3208,7 +3209,7 @@ printf '%s\n' '{{"type":"thread.started","thread_id":"restart"}}' let mut cfg = cfg(&jobs_dir, &database, &run_dir); cfg.agent_commands.codex = cli.bin(); let job = Catalog::load_named(&cfg, "restart").unwrap(); - let mut ledger = Ledger::open(&cfg.database_path).unwrap(); + let mut ledger = Ledger::open(&cfg.paths.database).unwrap(); let first_id = ledger .enqueue_scheduled(&job, &job.triggers[0], 60_000, 60_000, "telegram", "7") .unwrap(); @@ -3228,7 +3229,7 @@ printf '%s\n' '{{"type":"thread.started","thread_id":"restart"}}' restarted.shutdown().await; } - let rows = Ledger::open(&cfg.database_path) + let rows = Ledger::open(&cfg.paths.database) .unwrap() .runs(Some("restart")) .unwrap(); @@ -3263,7 +3264,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"delivery-only"}' let mut cfg = cfg(&jobs_dir, &database, &run_dir); cfg.agent_commands.codex = cli.bin(); let job = Catalog::load_named(&cfg, "delivery-only").unwrap(); - Ledger::open(&cfg.database_path) + Ledger::open(&cfg.paths.database) .unwrap() .enqueue_scheduled(&job, &job.triggers[0], 60_000, 60_000, "telegram", "7") .unwrap(); @@ -3285,7 +3286,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"delivery-only"}' .unwrap(); scheduler.shutdown().await; - let row = Ledger::open(&cfg.database_path) + let row = Ledger::open(&cfg.paths.database) .unwrap() .runs(Some("delivery-only")) .unwrap() @@ -3310,7 +3311,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"delivery-only"}' let mut cfg = cfg(&jobs_dir, &database, &run_dir); cfg.agent_commands.codex = slow.bin(); let job = Catalog::load_named(&cfg, "timeout").unwrap(); - Ledger::open(&cfg.database_path) + Ledger::open(&cfg.paths.database) .unwrap() .enqueue_scheduled(&job, &job.triggers[0], 60_000, 60_000, "telegram", "7") .unwrap(); @@ -3319,7 +3320,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"delivery-only"}' scheduler.tick(60_000, delivery_ok).await.unwrap(); scheduler.shutdown().await; - let rows = Ledger::open(&cfg.database_path) + let rows = Ledger::open(&cfg.paths.database) .unwrap() .runs(Some("timeout")) .unwrap(); @@ -3342,7 +3343,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"delivery-only"}' let mut cfg = cfg(&jobs_dir, &database, &run_dir); cfg.agent_commands.codex = failed.bin(); let job = Catalog::load_named(&cfg, "failure").unwrap(); - Ledger::open(&cfg.database_path) + Ledger::open(&cfg.paths.database) .unwrap() .enqueue_scheduled(&job, &job.triggers[0], 60_000, 60_000, "telegram", "7") .unwrap(); @@ -3351,7 +3352,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"delivery-only"}' scheduler.tick(60_000, delivery_ok).await.unwrap(); scheduler.shutdown().await; - let row = Ledger::open(&cfg.database_path) + let row = Ledger::open(&cfg.paths.database) .unwrap() .runs(Some("failure")) .unwrap() @@ -3389,7 +3390,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"limited"}' cfg.agent_commands.codex = cli.bin(); cfg.jobs_max_workers = 1; let catalog = Catalog::load(&cfg).unwrap(); - let mut ledger = Ledger::open(&cfg.database_path).unwrap(); + let mut ledger = Ledger::open(&cfg.paths.database).unwrap(); for job in catalog.jobs.values() { ledger .enqueue_scheduled(job, &job.triggers[0], 60_000, 60_000, "telegram", "7") @@ -3400,7 +3401,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"limited"}' scheduler.tick(60_000, delivery_ok).await.unwrap(); assert_eq!(scheduler.workers.len(), 1); for _ in 0..100 { - if Ledger::open(&cfg.database_path) + if Ledger::open(&cfg.paths.database) .unwrap() .queued_runs(10) .unwrap() @@ -3412,7 +3413,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"limited"}' tokio::task::yield_now().await; } assert_eq!( - Ledger::open(&cfg.database_path) + Ledger::open(&cfg.paths.database) .unwrap() .queued_runs(10) .unwrap() @@ -3424,7 +3425,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"limited"}' assert_eq!(scheduler.workers.len(), 1); scheduler.shutdown().await; - let rows = Ledger::open(&cfg.database_path) + let rows = Ledger::open(&cfg.paths.database) .unwrap() .runs(None) .unwrap(); @@ -3443,7 +3444,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"limited"}' write_job(&jobs_dir, "stale", &scheduled_job(&workdir, true)); let cfg = cfg(&jobs_dir, &database, &run_dir); let job = Catalog::load_named(&cfg, "stale").unwrap(); - let mut ledger = Ledger::open(&cfg.database_path).unwrap(); + let mut ledger = Ledger::open(&cfg.paths.database).unwrap(); let id = ledger .enqueue_scheduled(&job, &job.triggers[0], 60_000, 60_000, "telegram", "7") .unwrap(); @@ -3452,7 +3453,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"limited"}' panic!("scheduled run should claim"); }; - let mut restarted = Ledger::open(&cfg.database_path).unwrap(); + let mut restarted = Ledger::open(&cfg.paths.database).unwrap(); restarted.recover_stale_runs(&cfg, 61_000).unwrap(); assert_eq!(restarted.state(&id), "running"); drop(lock); @@ -3484,7 +3485,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"limited"}' ), ); let job = Catalog::load_named(&cfg, "recover-eval").unwrap(); - let mut ledger = Ledger::open(&cfg.database_path).unwrap(); + let mut ledger = Ledger::open(&cfg.paths.database).unwrap(); let run_id = ledger .enqueue_scheduled(&job, &job.triggers[0], 60_000, 60_000, "telegram", "7") .unwrap(); @@ -3567,7 +3568,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"after-crash"}' let mut restarted = Scheduler::new(cfg.clone(), "telegram".into(), "7".into()); restarted.tick(start + 60_000, delivery_ok).await.unwrap(); restarted.tick(start + 120_000, delivery_ok).await.unwrap(); - let live_rows = Ledger::open(&cfg.database_path) + let live_rows = Ledger::open(&cfg.paths.database) .unwrap() .runs(Some("cli-live")) .unwrap(); @@ -3579,7 +3580,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"after-crash"}' restarted.tick(start + 180_000, delivery_ok).await.unwrap(); restarted.shutdown().await; - let rows = Ledger::open(&cfg.database_path) + let rows = Ledger::open(&cfg.paths.database) .unwrap() .runs(Some("cli-live")) .unwrap(); @@ -3604,7 +3605,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"after-crash"}' write_job(&jobs_dir, "overlap", &scheduled_job(&workdir, true)); let cfg = cfg(&jobs_dir, &database, &run_dir); let job = Catalog::load_named(&cfg, "overlap").unwrap(); - let mut ledger = Ledger::open(&cfg.database_path).unwrap(); + let mut ledger = Ledger::open(&cfg.paths.database).unwrap(); let StartOutcome::Claimed { run_id, lock, .. } = ledger.start_manual(&cfg, &job).unwrap() else { panic!("manual run should hold the lock"); @@ -3634,7 +3635,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"after-crash"}' let updated = valid_job(&workdir).replace("Inspect this directory.", "Use the new body."); write_job(&jobs_dir, "snapshot", &updated); - let mut ledger = Ledger::open(&cfg.database_path).unwrap(); + let mut ledger = Ledger::open(&cfg.paths.database).unwrap(); let StartOutcome::Claimed { run_id, job, @@ -3682,7 +3683,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"after-crash"}' write_job(&jobs_dir, "daily-check", &valid_job(&workdir)); let cfg = cfg(&jobs_dir, &database, &run_dir); let job = Catalog::load_named(&cfg, "daily-check").unwrap(); - let mut first = Ledger::open(&cfg.database_path).unwrap(); + let mut first = Ledger::open(&cfg.paths.database).unwrap(); let StartOutcome::Claimed { run_id: first_id, lock, @@ -3691,7 +3692,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"after-crash"}' else { panic!("first run should claim"); }; - let mut second = Ledger::open(&cfg.database_path).unwrap(); + let mut second = Ledger::open(&cfg.paths.database).unwrap(); let StartOutcome::Skipped { run_id: skipped } = second.start_manual(&cfg, &job).unwrap() else { panic!("overlap should skip"); @@ -3718,7 +3719,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"after-crash"}' write_job(&jobs_dir, "persist", &valid_job(&workdir)); let cfg = cfg(&jobs_dir, &database, &run_dir); let job = Catalog::load_named(&cfg, "persist").unwrap(); - let mut ledger = Ledger::open(&cfg.database_path).unwrap(); + let mut ledger = Ledger::open(&cfg.paths.database).unwrap(); let StartOutcome::Claimed { run_id, lock, .. } = ledger.start_manual(&cfg, &job).unwrap() else { panic!("run should claim"); @@ -3735,7 +3736,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"after-crash"}' drop(lock); drop(ledger); - let reopened = Ledger::open(&cfg.database_path).unwrap(); + let reopened = Ledger::open(&cfg.paths.database).unwrap(); let rows = reopened.runs(Some("persist")).unwrap(); assert_eq!(rows[0].state, "failed"); assert_eq!(rows[0].error.as_deref(), Some("boom")); @@ -3755,7 +3756,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"after-crash"}' &job_with_eval(&workdir, "quality"), ); let job = Catalog::load_named(&cfg, "recover-eval").unwrap(); - let mut first = Ledger::open(&cfg.database_path).unwrap(); + let mut first = Ledger::open(&cfg.paths.database).unwrap(); let StartOutcome::Claimed { run_id, lock, .. } = first.start_manual(&cfg, &job).unwrap() else { panic!("run should claim"); @@ -3766,7 +3767,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"after-crash"}' drop(lock); drop(first); - let mut second = Ledger::open(&cfg.database_path).unwrap(); + let mut second = Ledger::open(&cfg.paths.database).unwrap(); let StartOutcome::Claimed { lock, .. } = second.start_manual(&cfg, &job).unwrap() else { panic!("new run should recover the interrupted evaluator"); }; @@ -3816,7 +3817,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"after-crash"}' write_job(&jobs_dir, "bounded", &valid_job(&workdir)); let cfg = cfg(&jobs_dir, &database, &run_dir); let job = Catalog::load_named(&cfg, "bounded").unwrap(); - let mut ledger = Ledger::open(&cfg.database_path).unwrap(); + let mut ledger = Ledger::open(&cfg.paths.database).unwrap(); let StartOutcome::Claimed { run_id, lock, .. } = ledger.start_manual(&cfg, &job).unwrap() else { panic!("run should claim"); @@ -3878,7 +3879,7 @@ printf '%s\n' '{{"type":"thread.started","thread_id":"fresh-thread"}}' let args = std::fs::read_to_string(&args_path).unwrap(); assert_eq!(args.lines().filter(|line| *line == "exec").count(), 2); assert!(!args.lines().any(|line| line == "resume")); - let rows = Ledger::open(&cfg.database_path) + let rows = Ledger::open(&cfg.paths.database) .unwrap() .runs(Some("execute")) .unwrap(); @@ -3935,7 +3936,7 @@ fi assert!(args.contains("# Original job")); assert!(args.contains("# Candidate response")); assert!(args.contains("The work must answer the request.")); - let rows = Ledger::open(&cfg.database_path) + let rows = Ledger::open(&cfg.paths.database) .unwrap() .runs(Some("evaluated")) .unwrap(); @@ -3966,7 +3967,7 @@ fi let job = Catalog::load_named(&cfg, "timeout").unwrap(); assert!(run_manual(&cfg, job).await.is_err()); - let rows = Ledger::open(&cfg.database_path) + let rows = Ledger::open(&cfg.paths.database) .unwrap() .runs(Some("timeout")) .unwrap(); @@ -3994,7 +3995,7 @@ printf '%s\n' ok > {} .await .unwrap(); assert_eq!(output.1, "recovered"); - let rows = Ledger::open(&cfg.database_path) + let rows = Ledger::open(&cfg.paths.database) .unwrap() .runs(Some("timeout")) .unwrap(); diff --git a/src/main.rs b/src/main.rs index a24761f..74aa66b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod history; mod imessage; mod jobs; mod markdown; +mod paths; mod pi; mod prompt; mod restart; @@ -29,7 +30,6 @@ mod voice; use anyhow::{bail, Context, Result}; -const DEFAULT_CONFIG_PATH: &str = "~/.push/config.toml"; const HELP: &str = "Push turns coding agents into a personal assistant you can text. Usage: push [OPTIONS] [COMMAND] @@ -48,9 +48,12 @@ Commands: job runs [name] Show job run history Options: - --config Use a configuration file (default: ~/.push/config.toml) + --config Use a configuration file (default: $PUSH_HOME/config.toml) -h, --help Print help -V, --version Print version + +Environment: + PUSH_HOME Runtime root (default: ~/.push) "; #[tokio::main] @@ -58,6 +61,14 @@ async fn main() -> Result<()> { tracing_subscriber::fmt().with_target(false).init(); let args = Args::parse(std::env::args().skip(1).collect())?; + let config_path = if matches!( + &args.command, + Command::Help | Command::Version | Command::Restart + ) { + None + } else { + Some(args.resolved_config_path()?) + }; match args.command { Command::Help => { print!("{HELP}"); @@ -68,7 +79,8 @@ async fn main() -> Result<()> { Ok(()) } Command::Init(path) => { - let result = assistant::init(&path, &args.config_path)?; + let config_path = config_path.expect("init resolves a config path"); + let result = assistant::init(&path, &config_path)?; println!("Initialized assistant at {}", result.root.display()); println!( "Configured assistant_root in {}", @@ -84,7 +96,7 @@ async fn main() -> Result<()> { println!(" $EDITOR {}/SOUL.md", result.root.display()); println!(" $EDITOR {}/context/README.md", result.root.display()); println!(" Validate and run:"); - if args.config_path == DEFAULT_CONFIG_PATH { + if args.config_path.is_none() { println!(" push doctor"); println!(" push"); } else { @@ -93,11 +105,17 @@ async fn main() -> Result<()> { } Ok(()) } - Command::Doctor => doctor::doctor(&args.config_path), + Command::Doctor => doctor::doctor(config_path.as_deref().expect("doctor has config")), Command::Restart => restart::gateway(), - Command::Job(command) => run_job_command(&args.config_path, command).await, + Command::Job(command) => { + run_job_command( + config_path.as_deref().expect("job command has config"), + command, + ) + .await + } Command::Run => { - let cfg = load_run_config(&args.config_path)?; + let cfg = load_run_config(config_path.as_deref().expect("run has config"))?; doctor::preflight(&cfg).context("preflight")?; report_invalid_jobs(&cfg)?; gateway::GatewayGroup::new(cfg).context("init")?.run().await @@ -121,9 +139,11 @@ fn missing_config_message(path: &str) -> Option { ) { return None; } - if path == DEFAULT_CONFIG_PATH { + if paths::PushPaths::discover() + .is_ok_and(|paths| paths.config == std::path::Path::new(&expanded_path)) + { return Some(format!( - "configuration not found at {path}\n\nCreate it with:\n push init\n\nThen configure a channel and run `push doctor`." + "configuration not found at {expanded_path}\n\nCreate it with:\n push init\n\nThen configure a channel and run `push doctor`." )); } let path_arg = shell_quote(path); @@ -146,7 +166,7 @@ fn shell_quote(value: &str) -> String { #[derive(Debug, PartialEq, Eq)] struct Args { command: Command, - config_path: String, + config_path: Option, } #[derive(Debug, PartialEq, Eq)] @@ -170,6 +190,22 @@ enum JobCommand { } impl Args { + fn resolved_config_path(&self) -> Result { + if let Some(path) = &self.config_path { + return Ok(path.clone()); + } + paths::PushPaths::discover()? + .config + .into_os_string() + .into_string() + .map_err(|path| { + anyhow::anyhow!( + "Push config path is not valid UTF-8: {}", + std::path::PathBuf::from(path).display() + ) + }) + } + fn parse(args: Vec) -> Result { if args .iter() @@ -177,7 +213,7 @@ impl Args { { return Ok(Self { command: Command::Help, - config_path: DEFAULT_CONFIG_PATH.to_string(), + config_path: None, }); } if args @@ -186,11 +222,11 @@ impl Args { { return Ok(Self { command: Command::Version, - config_path: DEFAULT_CONFIG_PATH.to_string(), + config_path: None, }); } - let mut config_path = DEFAULT_CONFIG_PATH.to_string(); + let mut config_path = None; let mut positional = Vec::new(); let mut i = 0; while i < args.len() { @@ -199,7 +235,7 @@ impl Args { let Some(path) = args.get(i + 1) else { bail!("--config requires a path"); }; - config_path = path.clone(); + config_path = Some(path.clone()); i += 2; } value => { @@ -276,7 +312,7 @@ async fn run_job_command(config_path: &str, command: JobCommand) -> Result<()> { if let Some(name) = name.as_deref() { jobs::validate_job_name(name)?; } - let ledger = jobs::Ledger::open(&cfg.database_path)?; + let ledger = jobs::Ledger::open(&cfg.paths.database)?; for run in ledger.runs(name.as_deref())? { let trigger = run .trigger_id @@ -352,6 +388,12 @@ mod tests { use crate::config::Config; use crate::test_support::{temp_dir, temp_path, test_config}; + fn write_config_with_assistant(path: &Path, body: &str) -> std::path::PathBuf { + let assistant = temp_dir("config-assistant"); + std::fs::write(path, format!("assistant_root = {:?}\n{body}", assistant)).unwrap(); + assistant + } + #[test] fn parses_doctor_with_config_path() { let args = Args::parse(vec![ @@ -365,7 +407,7 @@ mod tests { args, Args { command: Command::Doctor, - config_path: "custom.toml".to_string(), + config_path: Some("custom.toml".to_string()), } ); } @@ -383,7 +425,7 @@ mod tests { args, Args { command: Command::Restart, - config_path: "custom.toml".to_string(), + config_path: Some("custom.toml".to_string()), } ); } @@ -413,14 +455,14 @@ mod tests { Args::parse(vec!["help".into()]).unwrap(), Args { command: Command::Help, - config_path: DEFAULT_CONFIG_PATH.to_string(), + config_path: None, } ); assert_eq!( Args::parse(vec!["--help".into()]).unwrap(), Args { command: Command::Help, - config_path: DEFAULT_CONFIG_PATH.to_string(), + config_path: None, } ); assert_eq!( @@ -474,7 +516,7 @@ mod tests { .unwrap(), Args { command: Command::Job(JobCommand::List), - config_path: "x.toml".to_string(), + config_path: Some("x.toml".to_string()), } ); assert_eq!( @@ -519,7 +561,7 @@ mod tests { .unwrap(), Args { command: Command::Init("~/Code/assistant".to_string()), - config_path: "custom.toml".to_string(), + config_path: Some("custom.toml".to_string()), } ); } @@ -548,7 +590,7 @@ mod tests { Args::parse(Vec::new()).unwrap(), Args { command: Command::Run, - config_path: DEFAULT_CONFIG_PATH.to_string(), + config_path: None, } ); } @@ -579,10 +621,8 @@ mod tests { .to_string_lossy() ); assert_eq!( - cfg.database_path, - Path::new(&std::env::var("HOME").unwrap()) - .join(".push/push.db") - .to_string_lossy() + cfg.paths.database, + Path::new(&std::env::var("HOME").unwrap()).join(".push/push.db") ); assert_eq!( cfg.assistant_root, @@ -761,6 +801,145 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + #[test] + fn explicit_runtime_paths_take_precedence_over_push_home_defaults() { + let runtime = temp_dir("runtime-path-defaults"); + let assistant = temp_dir("runtime-path-assistant"); + let legacy = temp_dir("runtime-path-overrides"); + let path = temp_path("runtime-path-config"); + std::fs::write( + &path, + format!( + "self_handles = [\"me@icloud.com\"]\nassistant_root = {:?}\nstate_path = {:?}\ndatabase_path = {:?}\naudit_log_path = {:?}\njobs_run_dir = {:?}\n", + assistant, + legacy.join("state.json"), + legacy.join("push.db"), + legacy.join("audit.jsonl"), + legacy.join("run"), + ), + ) + .unwrap(); + + let cfg = Config::load_with_paths( + path.to_str().unwrap(), + crate::paths::PushPaths::from_root(runtime.clone()).unwrap(), + ) + .unwrap(); + + assert_eq!(cfg.paths.root, runtime); + assert_eq!(cfg.paths.config, cfg.paths.root.join("config.toml")); + assert_eq!(cfg.paths.state, legacy.join("state.json")); + assert_eq!(cfg.paths.database, legacy.join("push.db")); + assert_eq!(cfg.paths.audit, legacy.join("audit.jsonl")); + assert_eq!(cfg.paths.jobs_run, legacy.join("run")); + assert_eq!(cfg.paths.inbox, legacy.join("state.json.slack-inbox.db")); + assert_eq!(cfg.paths.cache, cfg.paths.root.join("cache")); + let _ = std::fs::remove_file(path); + let _ = std::fs::remove_dir_all(assistant); + let _ = std::fs::remove_dir_all(legacy); + let _ = std::fs::remove_dir_all(cfg.paths.root); + } + + #[test] + fn assistant_root_cannot_overlap_push_home() { + let runtime = temp_dir("runtime-overlap"); + let assistant = runtime.join("assistant"); + std::fs::create_dir(&assistant).unwrap(); + let path = temp_path("runtime-overlap-config"); + std::fs::write( + &path, + format!( + "self_handles = [\"me@icloud.com\"]\nassistant_root = {:?}\n", + assistant + ), + ) + .unwrap(); + + let error = Config::load_with_paths( + path.to_str().unwrap(), + crate::paths::PushPaths::from_root(runtime.clone()).unwrap(), + ) + .unwrap_err(); + + assert!(error.to_string().contains("must stay outside Push home")); + assert!(error.to_string().contains("set PUSH_HOME")); + let _ = std::fs::remove_file(path); + let _ = std::fs::remove_dir_all(runtime); + } + + #[test] + fn legacy_assistant_layout_cannot_overlap_push_home() { + let runtime = temp_dir("legacy-runtime-overlap"); + let path = temp_path("legacy-runtime-overlap-config"); + std::fs::write( + &path, + format!( + "self_handles = [\"me@icloud.com\"]\nassistant_dir = {:?}\njobs_dir = {:?}\n", + runtime, + runtime.join("jobs") + ), + ) + .unwrap(); + + let error = Config::load_with_paths( + path.to_str().unwrap(), + crate::paths::PushPaths::from_root(runtime.clone()).unwrap(), + ) + .unwrap_err(); + + assert!(error.to_string().contains("must stay outside Push home")); + let _ = std::fs::remove_file(path); + let _ = std::fs::remove_dir_all(runtime); + } + + #[test] + fn implicit_legacy_assistant_layout_cannot_overlap_push_home() { + let runtime = std::path::PathBuf::from(crate::util::expand_home("~/.push")); + let path = temp_path("implicit-legacy-runtime-overlap"); + std::fs::write(&path, "self_handles = [\"me@icloud.com\"]\n").unwrap(); + + let error = Config::load_with_paths( + path.to_str().unwrap(), + crate::paths::PushPaths::from_root(runtime.clone()).unwrap(), + ) + .unwrap_err(); + + assert!(error.to_string().contains("must stay outside Push home")); + assert!(error.to_string().contains("separate assistant repository")); + let _ = std::fs::remove_file(path); + } + + #[cfg(unix)] + #[test] + fn symlinked_push_home_cannot_hide_assistant_overlap() { + use std::os::unix::fs::symlink; + + let root = temp_dir("symlink-runtime-overlap"); + let runtime = root.join("runtime"); + let linked_runtime = root.join("linked-runtime"); + let assistant = runtime.join("assistant"); + std::fs::create_dir_all(&assistant).unwrap(); + symlink(&runtime, &linked_runtime).unwrap(); + let path = root.join("config.toml"); + std::fs::write( + &path, + format!( + "self_handles = [\"me@icloud.com\"]\nassistant_root = {:?}\n", + assistant + ), + ) + .unwrap(); + + let error = Config::load_with_paths( + path.to_str().unwrap(), + crate::paths::PushPaths::from_root(linked_runtime).unwrap(), + ) + .unwrap_err(); + + assert!(error.to_string().contains("must stay outside Push home")); + let _ = std::fs::remove_dir_all(root); + } + #[test] fn config_load_rejects_an_inline_token_added_inside_the_assistant() { let root = temp_dir("assistant-inline-token"); @@ -800,7 +979,7 @@ name = "push" #[test] fn provider_sections_load_channel_settings() { let path = temp_path("provider-section-config"); - std::fs::write( + let assistant = write_config_with_assistant( &path, r#"channel = "telegram" agent = "codex" @@ -822,8 +1001,7 @@ allow_user_ids = ["U1"] openai_api_key = "config-openai-key" name = "onyx" "#, - ) - .unwrap(); + ); let cfg = Config::load(path.to_str().unwrap()).unwrap(); @@ -840,12 +1018,13 @@ name = "onyx" ); assert_eq!(cfg.voice_name, "onyx"); let _ = std::fs::remove_file(path); + let _ = std::fs::remove_dir_all(assistant); } #[test] fn slack_config_requires_an_explicit_user_allowlist() { let path = temp_path("slack-allowlist-config"); - std::fs::write( + let assistant = write_config_with_assistant( &path, r#"channel = "slack" [slack] @@ -853,49 +1032,50 @@ app_token = "xapp-config" bot_token = "xoxb-config" allow_user_ids = [] "#, - ) - .unwrap(); + ); let error = Config::load(path.to_str().unwrap()).unwrap_err(); assert!(error .to_string() .contains("set slack.allow_user_ids to explicit Slack user IDs")); let _ = std::fs::remove_file(path); + let _ = std::fs::remove_dir_all(assistant); } #[test] fn voice_config_defaults_to_cedar_and_rejects_unknown_names() { let default_path = temp_path("default-voice-config"); - std::fs::write(&default_path, "self_handles = ['me@icloud.com']\n").unwrap(); + let default_assistant = + write_config_with_assistant(&default_path, "self_handles = ['me@icloud.com']\n"); let cfg = Config::load(default_path.to_str().unwrap()).unwrap(); assert_eq!(cfg.voice_name, "cedar"); let invalid_path = temp_path("invalid-voice-config"); - std::fs::write( + let invalid_assistant = write_config_with_assistant( &invalid_path, "self_handles = ['me@icloud.com']\n[voice]\nname = 'unknown'\n", - ) - .unwrap(); + ); let error = Config::load(invalid_path.to_str().unwrap()).unwrap_err(); assert!(error.to_string().contains("invalid voice.name \"unknown\"")); let _ = std::fs::remove_file(default_path); let _ = std::fs::remove_file(invalid_path); + let _ = std::fs::remove_dir_all(default_assistant); + let _ = std::fs::remove_dir_all(invalid_assistant); } #[test] fn voice_config_rejects_an_empty_openai_key() { let path = temp_path("empty-voice-key-config"); - std::fs::write( + let assistant = write_config_with_assistant( &path, r#"self_handles = ["me@icloud.com"] [voice] openai_api_key = " " "#, - ) - .unwrap(); + ); let error = Config::load(path.to_str().unwrap()).unwrap_err(); @@ -903,6 +1083,7 @@ openai_api_key = " " .to_string() .contains("voice.openai_api_key cannot be empty")); let _ = std::fs::remove_file(path); + let _ = std::fs::remove_dir_all(assistant); } #[test] @@ -1081,7 +1262,7 @@ job_permission_profiles = ["restricted"] fn loaded_config_file_is_shielded_from_job_workdirs() { let dir = temp_dir("config-shield-load"); let path = dir.join("config.toml"); - std::fs::write(&path, "self_handles = [\"me@icloud.com\"]\n").unwrap(); + let assistant = write_config_with_assistant(&path, "self_handles = [\"me@icloud.com\"]\n"); let cfg = Config::load(path.to_str().unwrap()).unwrap(); @@ -1091,12 +1272,13 @@ job_permission_profiles = ["restricted"] .to_string() .contains("config file")); let _ = std::fs::remove_dir_all(dir); + let _ = std::fs::remove_dir_all(assistant); } #[test] fn multi_channel_config_is_opt_in_and_defers_primary_resolution() { let path = temp_path("multi-channel-config"); - std::fs::write( + let assistant = write_config_with_assistant( &path, r#"channels = ["imessage", "telegram"] agent = "codex" @@ -1112,8 +1294,7 @@ allow_user_ids = [7] channel = "telegram" target = "not-an-allowed-target" "#, - ) - .unwrap(); + ); let cfg = Config::load(path.to_str().unwrap()).unwrap(); @@ -1129,25 +1310,26 @@ target = "not-an-allowed-target" }) ); let _ = std::fs::remove_file(path); + let _ = std::fs::remove_dir_all(assistant); } #[test] fn duplicate_enabled_channels_are_rejected() { let path = temp_path("duplicate-channel-config"); - std::fs::write( + let assistant = write_config_with_assistant( &path, r#"channels = ["telegram", "telegram"] [telegram] bot_token = "secret" allow_user_ids = [7] "#, - ) - .unwrap(); + ); let error = Config::load(path.to_str().unwrap()).unwrap_err(); assert!(error.to_string().contains("duplicate enabled channel")); let _ = std::fs::remove_file(path); + let _ = std::fs::remove_dir_all(assistant); } #[test] diff --git a/src/paths.rs b/src/paths.rs new file mode 100644 index 0000000..09c4b77 --- /dev/null +++ b/src/paths.rs @@ -0,0 +1,208 @@ +//! Resolution and ownership of Push-managed runtime paths. + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; + +use crate::util::expand_home; + +pub const PUSH_HOME_ENV: &str = "PUSH_HOME"; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PushPaths { + pub root: PathBuf, + pub config: PathBuf, + pub database: PathBuf, + pub state: PathBuf, + pub audit: PathBuf, + pub jobs_run: PathBuf, + pub inbox: PathBuf, + pub cache: PathBuf, +} + +impl PushPaths { + pub fn discover() -> Result { + Self::resolve( + std::env::var_os(PUSH_HOME_ENV).as_deref(), + std::env::var_os("HOME").as_deref(), + ) + } + + pub fn from_root(root: PathBuf) -> Result { + if root.as_os_str().is_empty() { + bail!("Push home cannot be empty"); + } + if !root.is_absolute() { + bail!("Push home must be an absolute path: {}", root.display()); + } + Ok(Self { + config: root.join("config.toml"), + database: root.join("push.db"), + state: root.join("state.json"), + audit: root.join("audit.jsonl"), + jobs_run: root.join("run"), + inbox: slack_inbox_for_state(&root.join("state.json")), + cache: root.join("cache"), + root, + }) + } + + pub fn with_overrides( + mut self, + state: Option<&str>, + database: Option<&str>, + audit: Option<&str>, + jobs_run: Option<&str>, + ) -> Result { + if let Some(value) = state { + self.state = configured_path("state_path", value)?; + self.inbox = slack_inbox_for_state(&self.state); + } + if let Some(value) = database { + self.database = configured_path("database_path", value)?; + } + if let Some(value) = audit { + self.audit = configured_path("audit_log_path", value)?; + } + if let Some(value) = jobs_run { + self.jobs_run = configured_path("jobs_run_dir", value)?; + } + Ok(self) + } + + fn resolve(push_home: Option<&OsStr>, home: Option<&OsStr>) -> Result { + let root = match push_home { + Some(value) => { + if value.is_empty() { + bail!("{PUSH_HOME_ENV} cannot be empty"); + } + PathBuf::from(value) + } + None => { + let home = home + .filter(|value| !value.is_empty()) + .context("HOME is not set; set PUSH_HOME to an absolute runtime directory")?; + Path::new(home).join(".push") + } + }; + Self::from_root(root).with_context(|| format!("resolve {PUSH_HOME_ENV}")) + } +} + +fn configured_path(label: &str, value: &str) -> Result { + if value.trim().is_empty() { + bail!("{label} cannot be empty"); + } + let expanded = expand_home(value); + if expanded.starts_with('~') { + bail!("cannot expand {label} {value:?}; set HOME or use an absolute path"); + } + Ok(PathBuf::from(expanded)) +} + +fn slack_inbox_for_state(state: &Path) -> PathBuf { + let mut path = state.as_os_str().to_os_string(); + path.push(".slack-inbox.db"); + PathBuf::from(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_home_derives_every_runtime_path() { + let paths = PushPaths::resolve(None, Some(OsStr::new("/Users/example"))).unwrap(); + + assert_eq!(paths.root, Path::new("/Users/example/.push")); + assert_eq!(paths.config, paths.root.join("config.toml")); + assert_eq!(paths.database, paths.root.join("push.db")); + assert_eq!(paths.state, paths.root.join("state.json")); + assert_eq!(paths.audit, paths.root.join("audit.jsonl")); + assert_eq!(paths.jobs_run, paths.root.join("run")); + assert_eq!(paths.inbox, paths.root.join("state.json.slack-inbox.db")); + assert_eq!(paths.cache, paths.root.join("cache")); + } + + #[test] + fn push_home_relocates_every_runtime_path() { + let paths = PushPaths::resolve( + Some(OsStr::new("/srv/push/alice")), + Some(OsStr::new("/ignored")), + ) + .unwrap(); + + for path in [ + &paths.config, + &paths.database, + &paths.state, + &paths.audit, + &paths.jobs_run, + &paths.inbox, + &paths.cache, + ] { + assert!(path.starts_with(&paths.root), "{}", path.display()); + } + assert_eq!(paths.root, Path::new("/srv/push/alice")); + } + + #[test] + fn explicit_legacy_paths_override_only_their_derived_defaults() { + let paths = PushPaths::from_root(PathBuf::from("/srv/push")) + .unwrap() + .with_overrides( + Some("/legacy/state.json"), + Some("/legacy/history.db"), + Some("/legacy/audit.jsonl"), + Some("/legacy/run"), + ) + .unwrap(); + + assert_eq!(paths.root, Path::new("/srv/push")); + assert_eq!(paths.config, Path::new("/srv/push/config.toml")); + assert_eq!(paths.state, Path::new("/legacy/state.json")); + assert_eq!(paths.inbox, Path::new("/legacy/state.json.slack-inbox.db")); + assert_eq!(paths.database, Path::new("/legacy/history.db")); + assert_eq!(paths.audit, Path::new("/legacy/audit.jsonl")); + assert_eq!(paths.jobs_run, Path::new("/legacy/run")); + assert_eq!(paths.cache, Path::new("/srv/push/cache")); + } + + #[test] + fn missing_home_requires_an_explicit_push_home() { + let error = PushPaths::resolve(None, None).unwrap_err(); + + assert!(error.to_string().contains("HOME is not set")); + assert!(error.to_string().contains("PUSH_HOME")); + } + + #[test] + fn two_push_homes_are_fully_isolated() { + let first = PushPaths::from_root(PathBuf::from("/srv/push/first")).unwrap(); + let second = PushPaths::from_root(PathBuf::from("/srv/push/second")).unwrap(); + + for first_path in [ + &first.config, + &first.database, + &first.state, + &first.audit, + &first.jobs_run, + &first.inbox, + &first.cache, + ] { + assert!(!first_path.starts_with(&second.root)); + } + for second_path in [ + &second.config, + &second.database, + &second.state, + &second.audit, + &second.jobs_run, + &second.inbox, + &second.cache, + ] { + assert!(!second_path.starts_with(&first.root)); + } + } +} diff --git a/src/slack.rs b/src/slack.rs index f553332..f7cead5 100644 --- a/src/slack.rs +++ b/src/slack.rs @@ -102,14 +102,13 @@ impl Slack { app_token: String, bot_token: String, allow_user_ids: Vec, - state_path: &str, + inbox_path: impl AsRef, ) -> Result { - let inbox_path = format!("{state_path}.slack-inbox.db"); Self::with_api_base( app_token, bot_token, allow_user_ids, - &inbox_path, + inbox_path.as_ref(), API_BASE.to_string(), ) } @@ -118,7 +117,7 @@ impl Slack { app_token: String, bot_token: String, allow_user_ids: Vec, - inbox_path: &str, + inbox_path: impl AsRef, api_base: String, ) -> Result { Ok(Self { @@ -409,15 +408,16 @@ async fn receive_loop(state: Arc) { } impl Inbox { - fn open(path: &str) -> Result { - if let Some(parent) = Path::new(path).parent() { + fn open(path: impl AsRef) -> Result { + let path = path.as_ref(); + if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("create Slack inbox directory {}", parent.display()))?; } - let connection = - Connection::open(path).with_context(|| format!("open Slack inbox {path}"))?; - crate::util::restrict_permissions(Path::new(path), false) - .with_context(|| format!("restrict Slack inbox permissions {path}"))?; + let connection = Connection::open(path) + .with_context(|| format!("open Slack inbox {}", path.display()))?; + crate::util::restrict_permissions(path, false) + .with_context(|| format!("restrict Slack inbox permissions {}", path.display()))?; connection .busy_timeout(Duration::from_secs(5)) .context("configure Slack inbox busy timeout")?; @@ -437,7 +437,7 @@ impl Inbox { )?; Ok(Self { connection, - path: path.to_string(), + path: path.to_string_lossy().to_string(), }) } diff --git a/src/store.rs b/src/store.rs index 1ff67ac..40b66c9 100644 --- a/src/store.rs +++ b/src/store.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use std::fs::OpenOptions; use std::io::{ErrorKind, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; @@ -38,16 +38,17 @@ pub struct Store { } impl Store { - pub fn open(path: &str) -> Result { - let p = PathBuf::from(path); + pub fn open(path: impl AsRef) -> Result { + let p = path.as_ref().to_path_buf(); + let display = p.display(); let state = match std::fs::read_to_string(&p) { Ok(s) => { crate::util::restrict_permissions(&p, false) - .with_context(|| format!("restrict state permissions {path}"))?; - serde_json::from_str(&s).with_context(|| format!("parse state {path}"))? + .with_context(|| format!("restrict state permissions {display}"))?; + serde_json::from_str(&s).with_context(|| format!("parse state {display}"))? } Err(e) if e.kind() == ErrorKind::NotFound => State::default(), - Err(e) => return Err(anyhow!("read state {path}: {e}")), + Err(e) => return Err(anyhow!("read state {display}: {e}")), }; Ok(Store { path: p, diff --git a/src/test_support.rs b/src/test_support.rs index 53a73bf..e979b18 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -65,13 +65,14 @@ pub fn test_config() -> crate::config::Config { jobs_dir: "/fake/jobs".to_string(), jobs_agent: None, jobs_max_timeout: "30m".to_string(), - jobs_run_dir: "/fake/run".to_string(), + jobs_run_dir_override: None, jobs_max_workers: 2, - state_path: "/fake/state.json".to_string(), - audit_log_path: "/fake/audit.jsonl".to_string(), - database_path: "/fake/push.db".to_string(), + state_path_override: None, + audit_log_path_override: None, + database_path_override: None, audit_log_content: false, config_path: String::new(), + paths: crate::paths::PushPaths::from_root(PathBuf::from("/fake")).unwrap(), agent_commands: crate::config::AgentCommands::default(), assistant_dir: "/fake/assistant".to_string(), } diff --git a/tests/docs.rs b/tests/docs.rs index deb465a..08f3b26 100644 --- a/tests/docs.rs +++ b/tests/docs.rs @@ -64,6 +64,23 @@ fn local_documentation_links_resolve() { ); } +#[test] +fn service_examples_select_one_push_home() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let launchd = + std::fs::read_to_string(root.join("examples/launchd/com.owainlewis.push.plist")).unwrap(); + let systemd = std::fs::read_to_string(root.join("examples/systemd/push.service")).unwrap(); + + assert!(launchd.contains("PUSH_HOME")); + assert!(launchd.contains("/Users/YOU/.push")); + assert!(!launchd.contains("--config")); + assert!(systemd.contains("Environment=PUSH_HOME=%h/.push")); + assert_eq!( + systemd.lines().find(|line| line.starts_with("ExecStart=")), + Some("ExecStart=%h/.local/bin/push") + ); +} + fn heading_anchors(markdown: &str) -> Vec { let mut anchors = Vec::new(); let mut heading = None; diff --git a/tests/init_cli.rs b/tests/init_cli.rs index 7ce4700..004c752 100644 --- a/tests/init_cli.rs +++ b/tests/init_cli.rs @@ -205,7 +205,10 @@ fn run_without_default_config_reports_first_run_guidance() { assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("configuration not found at ~/.push/config.toml")); + assert!(stderr.contains(&format!( + "configuration not found at {}", + home.join(".push/config.toml").display() + ))); assert!(stderr.contains("Create it with:\n push init")); assert!(!stderr.contains("push init --config")); assert!(stderr.contains("Then configure a channel")); @@ -372,7 +375,10 @@ fn assert_missing_default_config_guidance(args: &[&str]) { String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - assert!(combined.contains("configuration not found at ~/.push/config.toml")); + assert!(combined.contains(&format!( + "configuration not found at {}", + home.join(".push/config.toml").display() + ))); assert!(combined.contains("Create it with:\n push init")); assert!(!combined.contains("config.toml.example")); let _ = std::fs::remove_dir_all(root); @@ -397,6 +403,68 @@ fn run_with_missing_custom_config_reports_selected_path() { let _ = std::fs::remove_dir_all(root); } +#[test] +fn push_home_controls_the_default_config_location() { + let root = temp_dir("push-home"); + let home = root.join("home"); + let push_home = root.join("instances/primary"); + let workdir = root.join("workdir"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&workdir).unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_push")) + .arg("init") + .current_dir(&workdir) + .env("HOME", &home) + .env("PUSH_HOME", &push_home) + .output() + .unwrap(); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(push_home.join("config.toml").is_file()); + assert!(!home.join(".push/config.toml").exists()); + assert!(String::from_utf8_lossy(&output.stdout).contains(&format!( + "$EDITOR {}", + push_home.join("config.toml").display() + ))); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn explicit_config_path_takes_precedence_over_push_home() { + let root = temp_dir("push-home-explicit-config"); + let home = root.join("home"); + let push_home = root.join("instances/primary"); + let workdir = root.join("workdir"); + let config = root.join("selected/config.toml"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&workdir).unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_push")) + .args(["init", "--config"]) + .arg(&config) + .current_dir(&workdir) + .env("HOME", &home) + .env("PUSH_HOME", &push_home) + .output() + .unwrap(); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(config.is_file()); + assert!(!push_home.join("config.toml").exists()); + assert!(String::from_utf8_lossy(&output.stdout) + .contains(&format!("push doctor --config {}", config.display()))); + let _ = std::fs::remove_dir_all(root); +} + #[cfg(unix)] #[test] fn run_with_dangling_config_symlink_preserves_load_error() {