moadim 0.4.0

Moadim.io MCP/REST server for managing cron jobs
# Moadim Architecture

Moadim is a Rust daemon that manages cron jobs and exposes them over two protocols simultaneously — REST and MCP — on a single port (`127.0.0.1:5784`). It also serves an embedded browser UI compiled from a Yew/WASM workspace member.

---

## High-level picture

```
                ┌─────────────────────────────────────────┐
                │           Axum HTTP server :5784         │
                │                                          │
  Browser ──────┤  GET /ui          (inlined HTML+WASM)   │
  curl/SDK ─────┤  REST /cron-jobs  (JSON)                │
  AI agent ─────┤  /mcp             (MCP streamable-HTTP) │
                │                                          │
                │  Shared AppState:                        │
                │    CronStore   Arc<Mutex<HashMap>>       │
                │    HandlerRegistry  Arc<HashSet>         │
                └──────────────┬──────────────────────────┘
                               │ read+write on every mutation
               ~/.config/moadim/jobs/
               ├── <uuid>/job.toml          (tracked)
               ├── <uuid>/job.local.toml    (gitignored, local overrides)
               └── <uuid>/.gitignore
```

---

## Source layout

```
src/
├── main.rs              entry point — binds socket, loads store, starts server
├── lib.rs               library root — re-exports wasm module when target=wasm32
│
├── cron_jobs.rs         data model + service layer + Axum HTTP handlers
├── storage.rs           TOML persistence (load / write / remove)
├── system_cron.rs       read-only discovery of host cron jobs
├── fs_location.rs       captures working dir + exe dir for response headers
├── paths/mod.rs         path builders for ~/.config/moadim/jobs/
├── error.rs             AppError → HTTP status codes
├── banner.rs            startup banner
├── wasm.rs              wasm-bindgen exports (browser-side)
│
├── routes/
│   ├── http.rs          Axum router assembly + run_with_listener_until
│   └── mcp.rs           MoadimMcp — rmcp tool_router
│
├── middlewares/
│   ├── logger.rs        request/response logger
│   └── fs_location.rs   injects x-server-root / x-server-exe-dir headers
│
├── utils/
│   ├── time.rs          now_secs() — Unix timestamp helper
│   └── schema.rs        schemars override for free-form JSON metadata field
│
└── build/               build-script modules (compiled by build.rs, not the binary)
    ├── mod.rs
    ├── openapi.rs       writes apis/openapi.json
    ├── job_schema.rs    writes schemas/job.schema.json + job.example.toml
    └── ui.rs            runs trunk, inlines WASM → prebuilt.html / $OUT_DIR/index.html

ui/                      Yew workspace member (separate Cargo.toml)
tests/                   integration tests
```

---

## Core types

### `CronJob` (`src/cron_jobs.rs`)

```rust
pub struct CronJob {
    pub id: String,                       // UUID v4
    pub schedule: String,                 // cron expression
    pub handler: String,                  // name in ~/.config/moadim/handlers/
    pub metadata: serde_json::Value,      // arbitrary JSON object
    pub enabled: bool,
    pub source: String,                   // "managed" | "system:user-crontab" | "system:etc-crontab" | "system:cron.d/<file>"
    pub created_at: u64,                  // Unix seconds
    pub updated_at: u64,
    pub last_triggered_at: Option<u64>,
}
```

### `CronJobResponse`

`CronJob` + `handler_registered: bool` + `file_path: String`. Returned by all managed-job endpoints. `handler_registered` is true when the job's `handler` string appears in `HandlerRegistry`.

### `CronStore` / `HandlerRegistry`

```rust
pub type CronStore       = Arc<Mutex<HashMap<String, CronJob>>>;
pub type HandlerRegistry = Arc<HashSet<String>>;
```

Both are cloned into `AppState` (REST) and `MoadimMcp` (MCP). Every write acquires the mutex, updates in memory, then flushes to disk before releasing.

---

## Service layer

`src/cron_jobs.rs` exposes six functions that contain all business logic:

| Function | What it does |
|---|---|
| `svc_list` | Returns all jobs sorted by `created_at` |
| `svc_get` | Looks up one job, `NotFound` if absent |
| `svc_create` | Validates cron expr, assigns UUID v4, writes TOML, inserts into store |
| `svc_update` | Partial-updates fields, bumps `updated_at`, rewrites TOML |
| `svc_delete` | Removes from store, deletes job directory |
| `svc_trigger` | Records `last_triggered_at = now`, rewrites TOML |
| `svc_logs_path` | Checks job exists, returns path to `job.local.log` |

Both the HTTP handlers and MCP tools call these directly — there is no duplication of logic between the two transports.

---

## REST API

Router built in `src/routes/http.rs::build_app`. The full route list is the OpenAPI spec at `apis/openapi.json` (also served live at `/docs/openapi.json`).

Middleware stack (outermost first): `logger` → `fs_location`.

---

## MCP transport

`src/routes/mcp.rs` defines `MoadimMcp` with the `#[tool_router]` macro from `rmcp`. Each `#[tool]` method maps to an MCP tool. Tools exposed:

| MCP tool | Delegates to |
|---|---|
| `health` | `FsLocation::current()` + uptime calc |
| `echo` | inline |
| `list_cron_jobs` | `svc_list` |
| `list_system_cron_jobs` | `system_cron::read_all` |
| `get_cron_job` | `svc_get` |
| `create_cron_job` | `svc_create` |
| `update_cron_job` | `svc_update` |
| `delete_cron_job` | `svc_delete` |
| `trigger_cron_job` | `svc_trigger` |

Transport: `rmcp::transport::streamable_http_server::StreamableHttpService` with `LocalSessionManager`. Each MCP client gets its own session; the `MoadimMcp` handler is cloned per-session with shared `Arc` store and registry.

Connect from Claude Code:
```sh
claude mcp add --transport http moadim http://localhost:5784/mcp
```

---

## Persistence (`src/storage.rs`)

### On startup

`storage::load_store()` scans `~/.config/moadim/jobs/`. For each subdirectory it:
1. Reads `job.toml` (required)
2. Reads `job.local.toml` (optional override — local values win field-by-field)
3. Merges metadata tables (local keys overwrite base keys)
4. Constructs a `CronJob` with `source = "managed"`

Invalid or missing `job.toml` → directory silently skipped.

### On write

`storage::write_job` creates the job directory if absent, writes a fresh `.gitignore` (`*.local.*\n*.log\n`) if none exists, then serializes to `job.toml`. The `.gitignore` ensures secrets in `job.local.toml` and logs are never accidentally committed.

### File layout

```
~/.config/moadim/jobs/
└── <uuid>/
    ├── job.toml         schedule, handler, enabled, timestamps, [metadata]
    ├── job.local.toml   same schema, overrides job.toml (gitignored)
    ├── .gitignore       auto-created: *.local.* and *.log
    └── job.local.log    runtime log (gitignored)
```

Cron expression uses standard 5-field syntax (`min hour dom month dow`). The `cron` crate requires 7 fields internally; `normalize_cron` pads 5-field input to 7 before validation.

---

## System cron discovery (`src/system_cron.rs`)

Read-only. Called on-demand by `GET /system-cron-jobs` and the `list_system_cron_jobs` MCP tool. Not stored in `CronStore`.

Sources checked in order:
1. `crontab -l``source = "system:user-crontab"` (no user field)
2. `/etc/crontab``source = "system:etc-crontab"` (has user field)
3. `/etc/cron.d/<file>``source = "system:cron.d/<file>"` (has user field)

Handles both standard 5-field syntax and `@keyword` shortcuts. IDs are deterministic hashes of `(source, schedule, command)` so they are stable across calls. Lines starting with `#`, blank lines, and `KEY=value` env-var lines are skipped.

---

## Routines — agent-driven jobs (`src/routines.rs`)

A **routine** is a second kind of scheduled job whose payload is an AI agent (claude code, codex, …)
instead of a handler script. It carries `agent`, `prompt`, `repositories` (`{ repository, branch }`),
and a `title`. Routines are a separate type with their own store (`RoutineStore`), REST endpoints
(`/routines`), MCP tools (`create_routine`, …), and crontab block — they do not share `CronJob`.

When a routine fires there is **no moadim process in the loop and no clone step**. At create/update
time moadim composes `prompt.txt` (a repositories-as-context preamble + the prompt) into
`~/.config/moadim/routines/<id>/`, then writes a single self-contained shell command into a dedicated
crontab block:

```
# BEGIN MOADIM-ROUTINES
# Managed by moadim — routines (agent tmux sessions)
<sched> TS=$(date +\%s); WB=…/workbenches/<slug>-$TS; mkdir -p $WB; cp …/prompt.txt $WB/; \
  tmux new-session -d -s moadim-<slug>-$TS -c $WB '<agent-cmd>'; \
  tmux pipe-pane -o -t … "cat >> $WB/agent.log"   # moadim-routine:<id>
# END MOADIM-ROUTINES
```

OS cron runs that line directly: it makes a fresh empty workbench under `~/.moadim/workbenches/`,
launches the agent **interactively** (no `-p`) in a detached tmux session rooted there, and captures
output via `pipe-pane`. The prompt reaches the agent as a process **argument** (the `{prompt}`
placeholder expands to `"$(cat prompt.txt)"`), so there is no keystroke-injection readiness race. The
agent decides whether to clone the listed repositories. `POST /routines/{id}/trigger` runs the
identical command via `sh -c`.

The agent command is resolved from a configurable registry at `~/.config/moadim/agents/<name>.toml`
(`command`, `args`; placeholders `{prompt_file}` → `prompt.txt`, `{workbench}` → `.`,
`{prompt}` → `"$(cat prompt.txt)"`).
The resolved values are baked into the crontab line at sync time, so editing an agent config requires
re-syncing routines that use it. Routines with no matching agent config are skipped (with a warning).

Modules: `src/routines.rs` (model + service + command builder + handlers), `src/routine_storage.rs`
(`routine.toml` + `prompt.txt` persistence), `src/sync/routines.rs` (the `MOADIM-ROUTINES` block).
Reverse sync (crontab → store) is not implemented for routines.

## Error handling (`src/error.rs`)

```rust
enum AppError {
    Internal,           // 500 — disk I/O failures
    BadRequest(String), // 400 — invalid cron expression
    NotFound,           // 404 — job ID not in store
}
```

Implements `IntoResponse` → `{"error": "<message>"}` JSON body with matching status code. MCP tools use the `Display` impl of the same error type in a `CallToolResult::error` payload.

---

## Build-time code generation

`build.rs` compiles `src/build/` and runs three steps:

| Step | Output |
|---|---|
| `openapi::generate` | `apis/openapi.json` — hand-authored OpenAPI 3.0 spec |
| `job_schema::generate` | `schemas/job.schema.json`, `schemas/job.example.toml` |
| `ui::build` | `$OUT_DIR/index.html` — Yew UI inlined as single file |

### UI inlining strategy

`ui::build` runs `trunk build --release` in the `ui/` workspace member. Trunk emits a `.js` glue file and a `.wasm` binary. The build script then:
1. Base64-encodes the WASM bytes
2. Patches `globalThis.fetch` at runtime so any `*.wasm` request resolves to the inline bytes (avoids touching wasm-bindgen internals)
3. Inlines the JS module and the patched fetch shim into a single `<script type="module">` block
4. Writes the self-contained HTML to `$OUT_DIR/index.html`
5. Copies it to `prebuilt.html` at the package root so `cargo publish` ships it

If `trunk` is not installed, `prebuilt.html` is used instead. If neither exists, a placeholder page is shown with install instructions.

The prebuilt is stored at the package root — not under `ui/` — because `ui/` is a separate workspace member and `cargo publish` would strip it from the tarball.

---

## WASM module (`src/wasm.rs`)

When compiled for `target_arch = "wasm32"`, the binary becomes a WASM module with `wasm-bindgen` exports:

| Export | Description |
|---|---|
| `wasm_init()` | Initialize `console_log` |
| `wasm_query_health()` | `GET /health` → JSON string |
| `wasm_echo(message)` | `POST /echo` → JSON string |
| `wasm_get_info()` | `GET /info` → JSON string |
| `wasm_mode()` | Returns `"wasm"` |
| `wasm_checksum(input)` | DJB2 hash → hex string |
| `wasm_reverse(input)` | Reversed string |
| `wasm_uppercase(input)` | Uppercased string |

These are the bindings called by the Yew UI to communicate with the native server.

---

## Startup sequence

```
main()
  storage::load_store()          scan ~/.config/moadim/jobs/ → CronStore
  TcpListener::bind(:5784)
  routes::http::run_with_listener_until(store, listener, pending())
    build_app(store)
      AppState { store, handlers: new_registry() }
      StreamableHttpService::new(|| MoadimMcp::new(...))
      Router::new()  ← wire all routes + middleware
    banner::print(addr)          stdout: REST / MCP / UI URLs
    axum::serve(listener, app).with_graceful_shutdown(pending())
```

`std::future::pending()` means the server runs until the process is killed.

---

## Concurrency model

- **Single Tokio runtime** (`#[tokio::main]`), all async.
- **`CronStore`** is `Arc<Mutex<...>>` — synchronous lock, held only for the duration of the HashMap operation, then released before any disk I/O.
- **`HandlerRegistry`** is `Arc<HashSet<...>>` — read-only after creation, no locking needed.
- **MCP sessions** each get a cloned `Arc` of the same store, so mutations from REST and MCP are immediately visible to both.

---

## Testing

Tests are colocated: each module `foo.rs` has a companion `foo_tests.rs` and declares it with `#[cfg(test)] #[path = "foo_tests.rs"] mod foo_tests;`. 100% line coverage is enforced by a pre-push git hook.