Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ The default is `~/.push/config.toml`.
| `push init [path]` | Create and Git-initialize the one assistant repository; defaults to `./assistant` |
| `push` | Start the configured channel gateway and scheduler |
| `push doctor` | Validate config, paths, channel requirements, and required backend binaries |
| `push restart` | Restart the managed gateway to load updated config |
| `push job validate` | Validate every installed job; exits non-zero if any are invalid |
| `push job list` | List valid and invalid jobs with backend or error |
| `push job show <name>` | Print the parsed installed job |
Expand All @@ -21,6 +22,7 @@ Examples:
push init ~/Code/assistant
push doctor
push
push restart
push job validate
push job run repo-review
push job runs repo-review
Expand All @@ -29,6 +31,13 @@ push job runs repo-review
Unknown commands and missing values fail with the accepted command forms. The
CLI does not currently provide shell completion or a generated `--help` page.

`push restart` targets the service definitions documented by Push:
`com.owainlewis.push` under launchd on macOS and the `push.service` user unit
under systemd on Linux. The service definition controls its config path,
environment, and executable; `--config` does not override the service definition
for this command. Run `push doctor` separately when you want to validate those
settings from the current shell.

`push init` accepts an empty target, the selected config by itself, or a
complete existing assistant layout. It refuses unrelated and partial non-empty
directories, never overwrites an existing assistant file, persists one
Expand Down
12 changes: 12 additions & 0 deletions docs/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ launchctl print gui/$(id -u)/com.owainlewis.push
tail -f ~/Library/Logs/push.err.log ~/Library/Logs/push.out.log
```

After editing `~/.push/config.toml`, restart the gateway with:

```sh
push restart
```

After changing the plist:

```sh
Expand Down Expand Up @@ -158,6 +164,12 @@ systemctl --user status push.service
journalctl --user -u push.service -f
```

After editing `~/.push/config.toml`, restart the gateway with:

```sh
push restart
```

For voice support, prefer `voice.openai_api_key` in `~/.push/config.toml`. As an
alternative, create the optional private environment file:

Expand Down
17 changes: 6 additions & 11 deletions docs/telegram.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,21 +60,16 @@ voice transcription and spoken replies:
openai_api_key = "your-api-key"
```

Validate the config, then restart the one existing Push process:
Restart the managed gateway to load the updated config:

```sh
push doctor
push restart
```

For a foreground process, stop it and run `push` again. For launchd, use:

```sh
launchctl kickstart -k gui/$(id -u)/com.owainlewis.push
```

See [Running Push as a Service](services.md) for the full launchd and systemd
restart commands. Do not start a foreground gateway while the service is
running because Telegram permits only one poller for a bot token.
For a foreground process, stop it, run `push doctor`, and run `push` again.
See [Running Push as a Service](services.md) for launchd and systemd setup. Do
not start a foreground gateway while the service is running because Telegram
permits only one poller for a bot token.

`OPENAI_API_KEY` remains available for CI and service secret injection. A
non-empty environment value overrides the configured key.
Expand Down
24 changes: 23 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ mod imessage;
mod jobs;
mod pi;
mod rehydration;
mod restart;
mod soul;
mod store;
mod telegram;
Expand Down Expand Up @@ -63,6 +64,7 @@ async fn main() -> Result<()> {
Ok(())
}
Command::Doctor => doctor::doctor(&args.config_path),
Command::Restart => restart::gateway(),
Command::Job(command) => run_job_command(&args.config_path, command).await,
Command::Run => {
let cfg = load_run_config(&args.config_path)?;
Expand Down Expand Up @@ -122,6 +124,7 @@ enum Command {
Run,
Init(String),
Doctor,
Restart,
Job(JobCommand),
}

Expand Down Expand Up @@ -159,14 +162,15 @@ impl Args {
["init"] => Command::Init("./assistant".to_string()),
["init", path] => Command::Init((*path).to_string()),
["doctor"] => Command::Doctor,
["restart"] => Command::Restart,
["job", "validate"] => Command::Job(JobCommand::Validate),
["job", "list"] => Command::Job(JobCommand::List),
["job", "show", name] => Command::Job(JobCommand::Show((*name).to_string())),
["job", "run", name] => Command::Job(JobCommand::Run((*name).to_string())),
["job", "runs"] => Command::Job(JobCommand::Runs(None)),
["job", "runs", name] => Command::Job(JobCommand::Runs(Some((*name).to_string()))),
_ => bail!(
"unknown command; expected init [path], doctor, job validate, job list, job show <name>, job run <name>, job runs [<name>], or --config <path>"
"unknown command; expected init [path], doctor, restart, job validate, job list, job show <name>, job run <name>, job runs [<name>], or --config <path>"
),
};
Ok(Self {
Expand Down Expand Up @@ -306,6 +310,24 @@ mod tests {
);
}

#[test]
fn parses_restart_with_config_path() {
let args = Args::parse(vec![
"--config".to_string(),
"custom.toml".to_string(),
"restart".to_string(),
])
.unwrap();

assert_eq!(
args,
Args {
command: Command::Restart,
config_path: "custom.toml".to_string(),
}
);
}

#[test]
fn parses_all_job_commands_with_config_anywhere() {
assert_eq!(
Expand Down
194 changes: 194 additions & 0 deletions src/restart.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
use std::process::Command;

use anyhow::{bail, Context, Result};

const LAUNCHD_LABEL: &str = "com.owainlewis.push";
const SYSTEMD_UNIT: &str = "push.service";

pub fn gateway() -> Result<()> {
let command = platform_command()?;
let message = execute(&command, |command| {
let mut process = Command::new(command.program);
process.args(&command.args);
let status = process.status()?;
Ok(ProcessStatus {
success: status.success(),
description: status.to_string(),
})
})?;
println!("{message}");
Ok(())
}

struct ProcessStatus {
success: bool,
description: String,
}

#[derive(Debug, PartialEq, Eq)]
struct PlatformCommand {
program: &'static str,
args: Vec<String>,
}

impl PlatformCommand {
fn display(&self) -> String {
std::iter::once(self.program.to_string())
.chain(self.args.iter().cloned())
.collect::<Vec<_>>()
.join(" ")
}
}

fn execute(
command: &PlatformCommand,
runner: impl FnOnce(&PlatformCommand) -> std::io::Result<ProcessStatus>,
) -> Result<&'static str> {
let status = runner(command).with_context(|| format!("run {}", command.display()))?;
if !status.success {
bail!(
"gateway restart failed: {} exited with {}",
command.display(),
status.description
);
}
Ok("Restarted the Push gateway.")
}

fn platform_command() -> Result<PlatformCommand> {
command_for(std::env::consts::OS, effective_user_id()?.as_deref())
}

fn command_for(os: &str, user_id: Option<&str>) -> Result<PlatformCommand> {
match os {
"macos" => {
let user_id = user_id.context("determine current user id for launchd")?;
Ok(PlatformCommand {
program: "launchctl",
args: vec![
"kickstart".to_string(),
"-k".to_string(),
format!("gui/{user_id}/{LAUNCHD_LABEL}"),
],
})
}
"linux" => Ok(PlatformCommand {
program: "systemctl",
args: vec![
"--user".to_string(),
"restart".to_string(),
SYSTEMD_UNIT.to_string(),
],
}),
_ => bail!("gateway restart is supported only on macOS and Linux"),
}
}

fn effective_user_id() -> Result<Option<String>> {
if std::env::consts::OS != "macos" {
return Ok(None);
}
let output = Command::new("id").arg("-u").output().context("run id -u")?;
if !output.status.success() {
bail!("id -u exited with {}", output.status);
}
let user_id = String::from_utf8(output.stdout).context("read user id")?;
let user_id = user_id.trim();
if user_id.is_empty() || !user_id.bytes().all(|byte| byte.is_ascii_digit()) {
bail!("id -u returned an invalid user id");
}
Ok(Some(user_id.to_string()))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn macos_restarts_the_documented_launchd_service() {
assert_eq!(
command_for("macos", Some("501")).unwrap(),
PlatformCommand {
program: "launchctl",
args: vec![
"kickstart".to_string(),
"-k".to_string(),
"gui/501/com.owainlewis.push".to_string(),
],
}
);
}

#[test]
fn linux_restarts_the_documented_user_service() {
assert_eq!(
command_for("linux", None).unwrap(),
PlatformCommand {
program: "systemctl",
args: vec![
"--user".to_string(),
"restart".to_string(),
"push.service".to_string(),
],
}
);
}

#[test]
fn unsupported_platform_reports_the_supported_hosts() {
let error = command_for("windows", None).unwrap_err();

assert!(error
.to_string()
.contains("supported only on macOS and Linux"));
}

#[test]
fn successful_restart_reports_completion() {
let command = command_for("linux", None).unwrap();

let message = execute(&command, |_| {
Ok(ProcessStatus {
success: true,
description: "exit status: 0".to_string(),
})
})
.unwrap();

assert_eq!(message, "Restarted the Push gateway.");
}

#[test]
fn failed_restart_reports_the_command_and_status() {
let command = command_for("linux", None).unwrap();

let error = execute(&command, |_| {
Ok(ProcessStatus {
success: false,
description: "exit status: 5".to_string(),
})
})
.unwrap_err();

assert!(error
.to_string()
.contains("systemctl --user restart push.service exited with exit status: 5"));
}

#[test]
fn restart_spawn_failure_reports_the_command() {
let command = command_for("linux", None).unwrap();

let error = execute(&command, |_| {
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"missing service manager",
))
})
.unwrap_err();

assert!(error
.to_string()
.contains("run systemctl --user restart push.service"));
}
}
Loading
Loading