Skip to content

Commit c41c2de

Browse files
authored
feat: spawn worker process (#4)
* chore: convert sample to cli app * feat: implement worker process support * doc: update doc * feat: implement worker log streaming support * chore: clean up worker logging * feat: show db size
1 parent 4168bc0 commit c41c2de

13 files changed

Lines changed: 1108 additions & 57 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# Standalone Worker Process Control
2+
3+
## Summary
4+
5+
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.
6+
7+
## Goals
8+
9+
- Let users configure a worker command (e.g. `npx ...`, `uvx ...`) in Settings.
10+
- Allow start/stop control from Settings.
11+
- Display worker PID while running.
12+
- Display a crash indicator if the worker is crashing (rapid, repeated exits).
13+
- Pass required environment variables when starting the worker.
14+
- Capture and display recent worker logs (in-memory, capped).
15+
16+
## Non-Goals
17+
18+
- Managing auto-start on app launch beyond "start when command is set".
19+
- Managing multiple worker processes.
20+
- Persisting logs to disk or long-term log retention.
21+
- Bundling a worker binary with the app.
22+
23+
## User Experience
24+
25+
- Settings page shows a "Worker" card with:
26+
- Text input for a worker command.
27+
- Status line: "Running (PID ####)", "Stopped", or "Crashing".
28+
- Start/Stop button (disabled if no command is set).
29+
- Collapsible "Logs" section with a toggle; when open, display recent output.
30+
- Crash indicator appears if the worker exits unexpectedly multiple times within a short window (e.g., 3 exits within 60 seconds).
31+
32+
### UI Layout (Settings)
33+
34+
```
35+
Settings
36+
------------------------------------------------------------
37+
[Version Card] [Database Card]
38+
39+
[Migrations Card]
40+
41+
[Worker Card]
42+
------------------------------------------------------------
43+
Worker
44+
Run a local worker process for this database.
45+
46+
Command [ npx absurd-worker.................. ]
47+
Status [ Running (PID 12345) | Stopped | Crashing ]
48+
49+
[ Start/Stop ]
50+
51+
[ Logs ▾ ]
52+
------------------------------------------------------------
53+
12:01:22 [stdout] worker started
54+
12:01:23 [stderr] warning: ...
55+
...
56+
```
57+
58+
## Data Model & Persistence
59+
60+
- Use `tauri_plugin_store` to persist worker configuration in a JSON store (e.g. `worker.json`).
61+
- Keys:
62+
- `worker_binary_path` (string, command line).
63+
64+
## Backend Design (Tauri)
65+
66+
### New State
67+
68+
- `WorkerState` managed in `AppHandle`:
69+
- `binary_path: Mutex<Option<String>>`
70+
- `running: Mutex<Option<RunningWorker>>`
71+
- `crash_history: Mutex<VecDeque<Instant>>`
72+
- `log_buffer: Mutex<VecDeque<WorkerLogLine>>`
73+
- `RunningWorker`:
74+
- `pid: u32`
75+
- `child: tauri_plugin_shell::process::Child`
76+
- `rx: CommandEvent` receiver task handle
77+
- `WorkerLogLine`:
78+
- `timestamp: String` (formatted)
79+
- `stream: "stdout" | "stderr"`
80+
- `line: String`
81+
82+
### New Commands
83+
84+
- `get_worker_status` -> `{ configuredPath, running, pid, crashing }`
85+
- `set_worker_binary_path(path: String)` -> updated status
86+
- `start_worker()` -> updated status
87+
- `stop_worker()` -> updated status
88+
- `get_worker_logs` -> `{ lines: WorkerLogLine[] }`
89+
- `clear_worker_logs` -> `{ lines: WorkerLogLine[] }` (optional)
90+
91+
### Spawn Behavior
92+
93+
- Parse command into program + args (basic quoting supported).
94+
- Use `DatabaseHandle` to resolve the current `ABSURD_DATABASE_PATH`.
95+
- Expose a helper to resolve the bundled extension path from `db.rs` for `ABSURD_DATABASE_EXTENSION_PATH`.
96+
- Spawn using `tauri_plugin_shell`:
97+
- Command = configured program + args.
98+
- Env:
99+
- `ABSURD_DATABASE_PATH=<db_path>`
100+
- `ABSURD_DATABASE_EXTENSION_PATH=<extension_path>`
101+
- Capture stdout/stderr lines and append to `log_buffer`.
102+
- Track process exit:
103+
- If terminated while `start_worker` initiated and not explicitly stopped, record crash time.
104+
- Crash indicator = N exits within rolling window (e.g., 3 in 60s).
105+
- If command changes while running, stop the previous process and restart.
106+
- Attempt to start on app launch when a command is configured.
107+
108+
### Stop Behavior
109+
110+
- If running, send SIGTERM on Unix (fallback to kill on other platforms).
111+
- Clear `running` state.
112+
- Do not mark crash on user-initiated stop.
113+
114+
## Frontend Design (Svelte)
115+
116+
- Extend `SettingsInfo` or add a new API payload for worker status.
117+
- New UI section in `standalone/src/routes/settings/+page.svelte`:
118+
- Input bound to worker command.
119+
- Start/Stop button next to the status badge.
120+
- Toggleable logs section (collapsed by default).
121+
- When open, poll or subscribe to log updates from backend.
122+
- Status badge:
123+
- Running with PID.
124+
- Stopped.
125+
- Crashing (if `crashing === true`).
126+
- Refresh status on mount and after any start/stop/path changes.
127+
128+
## Error Handling
129+
130+
- Surface start/stop errors to the UI (e.g., toast or inline message).
131+
- If extension path cannot be resolved, block start and show error.
132+
- If log streaming fails, show a non-blocking message and keep controls active.
133+
134+
## Testing
135+
136+
- Backend unit tests for:
137+
- Parsing/persistence of stored worker path.
138+
- Crash indicator threshold logic.
139+
- Log buffer trimming to 500 lines.
140+
- Manual smoke test:
141+
- Set a valid worker path, start, verify PID.
142+
- Stop and verify state.
143+
- Use a dummy executable that exits immediately to trigger crash indicator.
144+
- Verify logs appear and cap at 500 lines.

samples/typescript-client/package-lock.json

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

samples/typescript-client/package.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
"name": "@absurd-sqlite-samples/typescript-client",
33
"version": "0.1.0",
44
"main": "index.js",
5+
"bin": {
6+
"absurd-sqlite-sample": "dist/absurd-sqlite-sample.js"
7+
},
58
"scripts": {
6-
"build": "esbuild src/index.ts --bundle --outfile=dist/bundle.js --platform=node --external:better-sqlite3",
7-
"start": "node dist/bundle.js",
9+
"build": "esbuild src/index.ts --bundle --outfile=dist/absurd-sqlite-sample.js --platform=node --external:better-sqlite3 --banner:js='#!/usr/bin/env node'",
10+
"start": "node dist/absurd-sqlite-sample.js",
811
"type-check": "tsc --noEmit"
912
},
1013
"author": "",
@@ -19,4 +22,4 @@
1922
"@absurd-sqlite/sdk": "file:../../sdks/typescript",
2023
"better-sqlite3": "^12.5.0"
2124
}
22-
}
25+
}

samples/typescript-client/src/index.ts

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,14 @@
1-
import { homedir } from "node:os";
2-
import { join } from "node:path";
31
import { Absurd, SQLiteDatabase } from "@absurd-sqlite/sdk";
42
import sqlite from "better-sqlite3";
53

64
async function main() {
7-
const extensionPath = "../../target/release/libabsurd.dylib";
8-
const bundleId = "ing.isbuild.absurd-sqlite-standalone";
9-
const appLocalDir =
10-
process.env.APP_LOCAL_DIR ??
11-
(process.platform === "darwin"
12-
? join(homedir(), "Library", "Application Support", bundleId)
13-
: process.platform === "win32"
14-
? join(
15-
process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"),
16-
bundleId
17-
)
18-
: join(homedir(), ".local", "share", bundleId));
19-
const dbPath = join(appLocalDir, "absurd-sqlite.db");
5+
const extensionPath = process.env.ABSURD_DATABASE_EXTENSION_PATH;
6+
const dbPath = process.env.ABSURD_DATABASE_PATH;
7+
if (!extensionPath || !dbPath) {
8+
throw new Error(
9+
"ABSURD_DATABASE_EXTENSION_PATH and ABSURD_DATABASE_PATH must be set",
10+
);
11+
}
2012
const db = sqlite(dbPath) as unknown as SQLiteDatabase;
2113

2214
const absurd = new Absurd(db, extensionPath);

standalone/src-tauri/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,8 @@ tauri-plugin-store = "2"
3434
tokio = "1.48.0"
3535
tower-http = {version = "0.5", features = ["cors"] }
3636

37+
[target.'cfg(unix)'.dependencies]
38+
libc = "0.2"
39+
3740
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
3841
tauri-plugin-cli = "2"

standalone/src-tauri/src/db.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,12 @@ impl DatabaseHandle {
110110
}
111111
}
112112

113+
pub fn extension_path(app_handle: &AppHandle) -> Result<String> {
114+
resolve_extension_path(app_handle)
115+
.map(|path| path.to_string_lossy().to_string())
116+
.ok_or_else(|| anyhow::anyhow!("SQLite extension not found"))
117+
}
118+
113119
fn resolve_extension_path(app_handle: &AppHandle) -> Option<PathBuf> {
114120
let lib_name = extension_lib_name();
115121
match app_handle.path().resource_dir() {

standalone/src-tauri/src/db_commands.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ pub struct SettingsInfo {
137137
pub absurd_version: String,
138138
pub sqlite_version: String,
139139
pub db_path: String,
140+
pub db_size_bytes: Option<u64>,
140141
pub migration: MigrationStatus,
141142
}
142143

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

559561
let applied_count: i64 = self
560562
.conn
@@ -588,6 +590,7 @@ impl<'a> TauriDataProvider<'a> {
588590
absurd_version,
589591
sqlite_version,
590592
db_path,
593+
db_size_bytes,
591594
migration: MigrationStatus {
592595
status,
593596
applied_count,

standalone/src-tauri/src/dev_api.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use tower_http::cors::CorsLayer;
1414

1515
use crate::db::DatabaseHandle;
1616
use crate::db_commands::{EventFilters, TaskRunFilters, TauriDataProvider};
17+
use crate::worker;
1718

1819
const DEV_API_PORT_DEFAULT: u16 = 11223;
1920
const DEV_API_PORT_ATTEMPTS: u16 = 10;
@@ -39,6 +40,12 @@ struct TrpcQuery {
3940
json: Option<String>,
4041
}
4142

43+
#[derive(Deserialize)]
44+
#[serde(rename_all = "camelCase")]
45+
struct WorkerPathInput {
46+
path: String,
47+
}
48+
4249
#[derive(Serialize)]
4350
#[serde(rename_all = "camelCase")]
4451
pub struct DevApiStatus {
@@ -400,6 +407,27 @@ fn handle_procedure(
400407
Ok(serde_json::to_value(info)?)
401408
})
402409
}
410+
"getWorkerStatus" => {
411+
let status = worker::get_worker_status_inner(app_handle)?;
412+
Ok(serde_json::to_value(status).map_err(|err| err.to_string())?)
413+
}
414+
"getWorkerLogs" => {
415+
let logs = worker::get_worker_logs(app_handle.clone())?;
416+
Ok(serde_json::to_value(logs).map_err(|err| err.to_string())?)
417+
}
418+
"setWorkerBinaryPath" => {
419+
let payload: WorkerPathInput = parse_input(input)?;
420+
let status = worker::set_worker_binary_path_inner(app_handle, &payload.path)?;
421+
Ok(serde_json::to_value(status).map_err(|err| err.to_string())?)
422+
}
423+
"startWorker" => {
424+
let status = worker::start_worker_inner(app_handle)?;
425+
Ok(serde_json::to_value(status).map_err(|err| err.to_string())?)
426+
}
427+
"stopWorker" => {
428+
let status = worker::stop_worker_inner(app_handle)?;
429+
Ok(serde_json::to_value(status).map_err(|err| err.to_string())?)
430+
}
403431
"getMigrations" => with_provider(app_handle, |provider| {
404432
let migrations = provider.get_migrations()?;
405433
Ok(serde_json::to_value(migrations)?)

standalone/src-tauri/src/lib.rs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use tauri::{async_runtime, Manager};
33
use tauri_plugin_cli::CliExt;
44

55
use crate::dev_api::{load_dev_api_enabled, DevApiState};
6-
use crate::{db::DatabaseHandle, worker::spawn_worker};
6+
use crate::{db::DatabaseHandle, worker::load_worker_binary_path};
77

88
mod db;
99
mod db_commands;
@@ -48,7 +48,12 @@ pub fn run() {
4848
db_commands::apply_migrations_all,
4949
db_commands::apply_migration,
5050
dev_api::get_dev_api_status,
51-
dev_api::set_dev_api_enabled
51+
dev_api::set_dev_api_enabled,
52+
worker::get_worker_status,
53+
worker::get_worker_logs,
54+
worker::set_worker_binary_path,
55+
worker::start_worker,
56+
worker::stop_worker
5257
])
5358
.on_menu_event(|_app, _event| {
5459
// DevTools is only available in debug builds
@@ -79,6 +84,8 @@ pub fn run() {
7984

8085
app_handle.manage(db_handle);
8186
app_handle.manage(DevApiState::new(enable_dev_api, None));
87+
let worker_path = load_worker_binary_path(&app_handle);
88+
app_handle.manage(worker::WorkerState::new(worker_path.clone()));
8289
if enable_dev_api {
8390
let app_handle = app_handle.clone();
8491
async_runtime::spawn(async move {
@@ -88,6 +95,15 @@ pub fn run() {
8895
});
8996
}
9097

98+
if worker_path.is_some() {
99+
let app_handle = app_handle.clone();
100+
async_runtime::spawn(async move {
101+
if let Err(err) = worker::start_worker_inner(&app_handle) {
102+
log::error!("Failed to start worker on launch: {}", err);
103+
}
104+
});
105+
}
106+
91107
#[cfg(not(any(target_os = "android", target_os = "ios")))]
92108
{
93109
let devtools = MenuItemBuilder::with_id(DEVTOOLS_MENU_ID, "Open DevTools")
@@ -109,8 +125,6 @@ pub fn run() {
109125
app.set_menu(menu)?;
110126
}
111127

112-
async_runtime::spawn(async move { spawn_worker(&app_handle).await });
113-
114128
log::info!("setup");
115129

116130
Ok(())

0 commit comments

Comments
 (0)