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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

144 changes: 144 additions & 0 deletions docs/specs/standalone-worker-process.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# Standalone Worker Process Control

## Summary

Add support for starting/stopping a user-specified worker command from the Standalone Settings page. The GUI will spawn the worker with `ABSURD_DATABASE_PATH` (current database path) and `ABSURD_DATABASE_EXTENSION_PATH` (bundled extension path) set, show the running PID, surface a crash indicator when the worker repeatedly exits unexpectedly, and stream recent worker logs.

## Goals

- Let users configure a worker command (e.g. `npx ...`, `uvx ...`) in Settings.
- Allow start/stop control from Settings.
- Display worker PID while running.
- Display a crash indicator if the worker is crashing (rapid, repeated exits).
- Pass required environment variables when starting the worker.
- Capture and display recent worker logs (in-memory, capped).

## Non-Goals

- Managing auto-start on app launch beyond "start when command is set".
- Managing multiple worker processes.
- Persisting logs to disk or long-term log retention.
- Bundling a worker binary with the app.

## User Experience

- Settings page shows a "Worker" card with:
- Text input for a worker command.
- Status line: "Running (PID ####)", "Stopped", or "Crashing".
- Start/Stop button (disabled if no command is set).
- Collapsible "Logs" section with a toggle; when open, display recent output.
- Crash indicator appears if the worker exits unexpectedly multiple times within a short window (e.g., 3 exits within 60 seconds).

### UI Layout (Settings)

```
Settings
------------------------------------------------------------
[Version Card] [Database Card]

[Migrations Card]

[Worker Card]
------------------------------------------------------------
Worker
Run a local worker process for this database.

Command [ npx absurd-worker.................. ]
Status [ Running (PID 12345) | Stopped | Crashing ]

[ Start/Stop ]

[ Logs ▾ ]
------------------------------------------------------------
12:01:22 [stdout] worker started
12:01:23 [stderr] warning: ...
...
```

## Data Model & Persistence

- Use `tauri_plugin_store` to persist worker configuration in a JSON store (e.g. `worker.json`).
- Keys:
- `worker_binary_path` (string, command line).

## Backend Design (Tauri)

### New State

- `WorkerState` managed in `AppHandle`:
- `binary_path: Mutex<Option<String>>`
- `running: Mutex<Option<RunningWorker>>`
- `crash_history: Mutex<VecDeque<Instant>>`
- `log_buffer: Mutex<VecDeque<WorkerLogLine>>`
- `RunningWorker`:
- `pid: u32`
- `child: tauri_plugin_shell::process::Child`
- `rx: CommandEvent` receiver task handle
- `WorkerLogLine`:
- `timestamp: String` (formatted)
- `stream: "stdout" | "stderr"`
- `line: String`

### New Commands

- `get_worker_status` -> `{ configuredPath, running, pid, crashing }`
- `set_worker_binary_path(path: String)` -> updated status
- `start_worker()` -> updated status
- `stop_worker()` -> updated status
- `get_worker_logs` -> `{ lines: WorkerLogLine[] }`
- `clear_worker_logs` -> `{ lines: WorkerLogLine[] }` (optional)

### Spawn Behavior

- Parse command into program + args (basic quoting supported).
- Use `DatabaseHandle` to resolve the current `ABSURD_DATABASE_PATH`.
- Expose a helper to resolve the bundled extension path from `db.rs` for `ABSURD_DATABASE_EXTENSION_PATH`.
- Spawn using `tauri_plugin_shell`:
- Command = configured program + args.
- Env:
- `ABSURD_DATABASE_PATH=<db_path>`
- `ABSURD_DATABASE_EXTENSION_PATH=<extension_path>`
- Capture stdout/stderr lines and append to `log_buffer`.
- Track process exit:
- If terminated while `start_worker` initiated and not explicitly stopped, record crash time.
- Crash indicator = N exits within rolling window (e.g., 3 in 60s).
- If command changes while running, stop the previous process and restart.
- Attempt to start on app launch when a command is configured.

### Stop Behavior

- If running, send SIGTERM on Unix (fallback to kill on other platforms).
- Clear `running` state.
- Do not mark crash on user-initiated stop.

## Frontend Design (Svelte)

- Extend `SettingsInfo` or add a new API payload for worker status.
- New UI section in `standalone/src/routes/settings/+page.svelte`:
- Input bound to worker command.
- Start/Stop button next to the status badge.
- Toggleable logs section (collapsed by default).
- When open, poll or subscribe to log updates from backend.
- Status badge:
- Running with PID.
- Stopped.
- Crashing (if `crashing === true`).
- Refresh status on mount and after any start/stop/path changes.

## Error Handling

- Surface start/stop errors to the UI (e.g., toast or inline message).
- If extension path cannot be resolved, block start and show error.
- If log streaming fails, show a non-blocking message and keep controls active.

## Testing

- Backend unit tests for:
- Parsing/persistence of stored worker path.
- Crash indicator threshold logic.
- Log buffer trimming to 500 lines.
- Manual smoke test:
- Set a valid worker path, start, verify PID.
- Stop and verify state.
- Use a dummy executable that exits immediately to trigger crash indicator.
- Verify logs appear and cap at 500 lines.
3 changes: 3 additions & 0 deletions samples/typescript-client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 6 additions & 3 deletions samples/typescript-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
"name": "@absurd-sqlite-samples/typescript-client",
"version": "0.1.0",
"main": "index.js",
"bin": {
"absurd-sqlite-sample": "dist/absurd-sqlite-sample.js"
},
"scripts": {
"build": "esbuild src/index.ts --bundle --outfile=dist/bundle.js --platform=node --external:better-sqlite3",
"start": "node dist/bundle.js",
"build": "esbuild src/index.ts --bundle --outfile=dist/absurd-sqlite-sample.js --platform=node --external:better-sqlite3 --banner:js='#!/usr/bin/env node'",
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bin field points to "dist/absurd-sqlite-sample.js" but this file needs to be executable on Unix systems. While the shebang is added via the esbuild banner, the file permissions need to be set appropriately (chmod +x) for the bin to work correctly when installed globally.

Consider documenting that users need to run chmod +x on the generated file, or add a postbuild step to set the executable permission automatically.

Suggested change
"build": "esbuild src/index.ts --bundle --outfile=dist/absurd-sqlite-sample.js --platform=node --external:better-sqlite3 --banner:js='#!/usr/bin/env node'",
"build": "esbuild src/index.ts --bundle --outfile=dist/absurd-sqlite-sample.js --platform=node --external:better-sqlite3 --banner:js='#!/usr/bin/env node'",
"postbuild": "chmod +x dist/absurd-sqlite-sample.js",

Copilot uses AI. Check for mistakes.
"start": "node dist/absurd-sqlite-sample.js",
"type-check": "tsc --noEmit"
},
"author": "",
Expand All @@ -19,4 +22,4 @@
"@absurd-sqlite/sdk": "file:../../sdks/typescript",
"better-sqlite3": "^12.5.0"
}
}
}
22 changes: 7 additions & 15 deletions samples/typescript-client/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,14 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { Absurd, SQLiteDatabase } from "@absurd-sqlite/sdk";
import sqlite from "better-sqlite3";

async function main() {
const extensionPath = "../../target/release/libabsurd.dylib";
const bundleId = "ing.isbuild.absurd-sqlite-standalone";
const appLocalDir =
process.env.APP_LOCAL_DIR ??
(process.platform === "darwin"
? join(homedir(), "Library", "Application Support", bundleId)
: process.platform === "win32"
? join(
process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"),
bundleId
)
: join(homedir(), ".local", "share", bundleId));
const dbPath = join(appLocalDir, "absurd-sqlite.db");
const extensionPath = process.env.ABSURD_DATABASE_EXTENSION_PATH;
const dbPath = process.env.ABSURD_DATABASE_PATH;
if (!extensionPath || !dbPath) {
throw new Error(
"ABSURD_DATABASE_EXTENSION_PATH and ABSURD_DATABASE_PATH must be set",
);
}
const db = sqlite(dbPath) as unknown as SQLiteDatabase;

const absurd = new Absurd(db, extensionPath);
Expand Down
3 changes: 3 additions & 0 deletions standalone/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,8 @@ tauri-plugin-store = "2"
tokio = "1.48.0"
tower-http = {version = "0.5", features = ["cors"] }

[target.'cfg(unix)'.dependencies]
libc = "0.2"

[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-cli = "2"
6 changes: 6 additions & 0 deletions standalone/src-tauri/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ impl DatabaseHandle {
}
}

pub fn extension_path(app_handle: &AppHandle) -> Result<String> {
resolve_extension_path(app_handle)
.map(|path| path.to_string_lossy().to_string())
.ok_or_else(|| anyhow::anyhow!("SQLite extension not found"))
}

fn resolve_extension_path(app_handle: &AppHandle) -> Option<PathBuf> {
let lib_name = extension_lib_name();
match app_handle.path().resource_dir() {
Expand Down
3 changes: 3 additions & 0 deletions standalone/src-tauri/src/db_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ pub struct SettingsInfo {
pub absurd_version: String,
pub sqlite_version: String,
pub db_path: String,
pub db_size_bytes: Option<u64>,
pub migration: MigrationStatus,
}

Expand Down Expand Up @@ -555,6 +556,7 @@ impl<'a> TauriDataProvider<'a> {
let sqlite_version: String = self
.conn
.query_row("select sqlite_version()", [], |row| row.get(0))?;
let db_size_bytes = std::fs::metadata(&db_path).map(|meta| meta.len()).ok();

let applied_count: i64 = self
.conn
Expand Down Expand Up @@ -588,6 +590,7 @@ impl<'a> TauriDataProvider<'a> {
absurd_version,
sqlite_version,
db_path,
db_size_bytes,
migration: MigrationStatus {
status,
applied_count,
Expand Down
28 changes: 28 additions & 0 deletions standalone/src-tauri/src/dev_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use tower_http::cors::CorsLayer;

use crate::db::DatabaseHandle;
use crate::db_commands::{EventFilters, TaskRunFilters, TauriDataProvider};
use crate::worker;

const DEV_API_PORT_DEFAULT: u16 = 11223;
const DEV_API_PORT_ATTEMPTS: u16 = 10;
Expand All @@ -39,6 +40,12 @@ struct TrpcQuery {
json: Option<String>,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WorkerPathInput {
path: String,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DevApiStatus {
Expand Down Expand Up @@ -400,6 +407,27 @@ fn handle_procedure(
Ok(serde_json::to_value(info)?)
})
}
"getWorkerStatus" => {
let status = worker::get_worker_status_inner(app_handle)?;
Ok(serde_json::to_value(status).map_err(|err| err.to_string())?)
}
"getWorkerLogs" => {
let logs = worker::get_worker_logs(app_handle.clone())?;
Ok(serde_json::to_value(logs).map_err(|err| err.to_string())?)
}
"setWorkerBinaryPath" => {
let payload: WorkerPathInput = parse_input(input)?;
let status = worker::set_worker_binary_path_inner(app_handle, &payload.path)?;
Ok(serde_json::to_value(status).map_err(|err| err.to_string())?)
}
"startWorker" => {
let status = worker::start_worker_inner(app_handle)?;
Ok(serde_json::to_value(status).map_err(|err| err.to_string())?)
}
"stopWorker" => {
let status = worker::stop_worker_inner(app_handle)?;
Ok(serde_json::to_value(status).map_err(|err| err.to_string())?)
}
"getMigrations" => with_provider(app_handle, |provider| {
let migrations = provider.get_migrations()?;
Ok(serde_json::to_value(migrations)?)
Expand Down
22 changes: 18 additions & 4 deletions standalone/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use tauri::{async_runtime, Manager};
use tauri_plugin_cli::CliExt;

use crate::dev_api::{load_dev_api_enabled, DevApiState};
use crate::{db::DatabaseHandle, worker::spawn_worker};
use crate::{db::DatabaseHandle, worker::load_worker_binary_path};

mod db;
mod db_commands;
Expand Down Expand Up @@ -48,7 +48,12 @@ pub fn run() {
db_commands::apply_migrations_all,
db_commands::apply_migration,
dev_api::get_dev_api_status,
dev_api::set_dev_api_enabled
dev_api::set_dev_api_enabled,
worker::get_worker_status,
worker::get_worker_logs,
worker::set_worker_binary_path,
worker::start_worker,
worker::stop_worker
])
.on_menu_event(|_app, _event| {
// DevTools is only available in debug builds
Expand Down Expand Up @@ -79,6 +84,8 @@ pub fn run() {

app_handle.manage(db_handle);
app_handle.manage(DevApiState::new(enable_dev_api, None));
let worker_path = load_worker_binary_path(&app_handle);
app_handle.manage(worker::WorkerState::new(worker_path.clone()));
if enable_dev_api {
let app_handle = app_handle.clone();
async_runtime::spawn(async move {
Expand All @@ -88,6 +95,15 @@ pub fn run() {
});
}

if worker_path.is_some() {
let app_handle = app_handle.clone();
async_runtime::spawn(async move {
if let Err(err) = worker::start_worker_inner(&app_handle) {
log::error!("Failed to start worker on launch: {}", err);
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The auto-start logic spawns a task to start the worker but doesn't wait for the WorkerState to be fully initialized. Since app_handle.manage(worker::WorkerState::new(...)) happens at line 88 and the spawn happens immediately after, there's a potential race where worker commands from the frontend could be called before the WorkerState is managed. However, this is likely safe in practice since Tauri setup completes before handling commands.

A more significant issue is that the worker is started asynchronously without any feedback to the user. If the worker fails to start on launch (line 102), the error is only logged but the user won't know unless they check the logs. Consider tracking this initial start failure in the WorkerState so the UI can display it.

Suggested change
log::error!("Failed to start worker on launch: {}", err);
log::error!("Failed to start worker on launch: {}", err);
// Notify the frontend so it can surface the failure to the user.
let _ = app_handle.emit_all("worker_start_failed", err.to_string());

Copilot uses AI. Check for mistakes.
}
});
}

#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
let devtools = MenuItemBuilder::with_id(DEVTOOLS_MENU_ID, "Open DevTools")
Expand All @@ -109,8 +125,6 @@ pub fn run() {
app.set_menu(menu)?;
}

async_runtime::spawn(async move { spawn_worker(&app_handle).await });

log::info!("setup");

Ok(())
Expand Down
Loading