|
| 1 | +# Linux support — Option A: graceful automation skip on non-macOS |
| 2 | + |
| 3 | +## Problem |
| 4 | + |
| 5 | +Every lifecycle and read command already runs on Linux — `init`, `ingest`, |
| 6 | +`garden`, `search`, indexing, the harness, telemetry are all platform-neutral |
| 7 | +Python. The single blocker is **scheduled automation**. `create_app` wires |
| 8 | +`AutomationService` with `LaunchdSchedulerAdapter`, which shells out to |
| 9 | +`launchctl`. On Linux `launchctl` does not exist, so: |
| 10 | + |
| 11 | +- `codealmanac setup --yes` crashes with |
| 12 | + `launchctl bootstrap failed for com.codealmanac.sync: [Errno 2] ... 'launchctl'` |
| 13 | + and exits 1 (issue #31). |
| 14 | +- `config set automation.*` hits the same path (both routes run |
| 15 | + `AutomationService.reconcile_task` → `scheduler.install`). |
| 16 | +- `config.toml` is written *before* the scheduler runs, so the crash leaves a |
| 17 | + half-configured global state. |
| 18 | + |
| 19 | +This is **not** a rewrite. The `SchedulerAdapter` port already exists |
| 20 | +(`services/automation/ports.py`, 3 methods) and `create_app` already accepts a |
| 21 | +`scheduler` override. Launchd is just one implementation of that port. |
| 22 | + |
| 23 | +## Scope (Option A) |
| 24 | + |
| 25 | +Make the whole product usable on Linux **today** by degrading scheduled |
| 26 | +automation gracefully instead of crashing. Scheduled background runs are not |
| 27 | +provided on Linux in this slice; the user runs `codealmanac sync` / `garden` |
| 28 | +manually (or wires their own cron). Real Linux scheduling (systemd user timers) |
| 29 | +is **Option B**, a separate slice. |
| 30 | + |
| 31 | +### In scope |
| 32 | + |
| 33 | +1. Platform capability predicate in `core/` — single source of truth. |
| 34 | +2. `UnsupportedSchedulerAdapter` implementing the existing port as a no-op. |
| 35 | +3. Composition root selects the adapter by platform (launchd on macOS, |
| 36 | + unsupported elsewhere). Only branch point for the platform fact in wiring. |
| 37 | +4. Setup and automation-status render tell the user clearly, on non-macOS, that |
| 38 | + scheduled automation is unavailable — and stop claiming schedules are |
| 39 | + "installed"/"automatic" when nothing was scheduled. |
| 40 | +5. Suppress the macOS-only "Background Items Added" heads-up off-macOS. |
| 41 | + |
| 42 | +### Out of scope |
| 43 | + |
| 44 | +- systemd / cron backends (Option B). |
| 45 | +- Renaming `plist_path` / `Library/LaunchAgents` in the shared model (Option B |
| 46 | + cleanup — the null adapter never touches those paths, so it can wait). |
| 47 | +- Forcing `automation.*.enabled = false` in config on Linux. We keep config as |
| 48 | + recorded intent so Option B activates it later without a re-run. Render, not |
| 49 | + config, is where we tell the truth about what actually got scheduled. |
| 50 | +- README / docs edits (fold into the commit if trivial; not gated here). |
| 51 | + |
| 52 | +## Design |
| 53 | + |
| 54 | +### Single platform predicate |
| 55 | + |
| 56 | +New `src/codealmanac/core/platform.py`: |
| 57 | + |
| 58 | +```python |
| 59 | +import sys |
| 60 | + |
| 61 | +def scheduler_supported() -> bool: |
| 62 | + """True when this platform has a scheduler backend CodeAlmanac can drive. |
| 63 | +
|
| 64 | + Today the only backend is macOS launchd. Option B (systemd user timers) |
| 65 | + will widen this. Consulted at the composition root (adapter selection) and |
| 66 | + in render (what to tell the user about scheduling). |
| 67 | + """ |
| 68 | + return sys.platform == "darwin" |
| 69 | +``` |
| 70 | + |
| 71 | +One predicate, two honest consult sites (wiring + render). This is dependency |
| 72 | +injection + presentation, not a scattered special-case: there is exactly one |
| 73 | +definition of "can we schedule here". |
| 74 | + |
| 75 | +### Null adapter |
| 76 | + |
| 77 | +New `src/codealmanac/integrations/automation/scheduler/unsupported.py`: |
| 78 | + |
| 79 | +```python |
| 80 | +class UnsupportedSchedulerAdapter: |
| 81 | + def install(self, job): return _not_installed(job) |
| 82 | + def uninstall(self, job): return False |
| 83 | + def status(self, job): return _not_installed(job) |
| 84 | +``` |
| 85 | + |
| 86 | +`_not_installed` returns `ScheduledJobStatus(installed=False, loaded=False)`. |
| 87 | +No filesystem writes, no `Library/LaunchAgents` dirs, no subprocess. Because |
| 88 | +`install()` no longer raises, `setup` completes and exits 0 — which also |
| 89 | +**removes the half-configured-state bug on Linux** (nothing fails, so nothing |
| 90 | +is left partial). `reconcile_task` already ignores the `install()` return value. |
| 91 | + |
| 92 | +Export it from `integrations/automation/scheduler/__init__.py` and |
| 93 | +`integrations/automation/__init__.py`. |
| 94 | + |
| 95 | +### Composition root selects the adapter |
| 96 | + |
| 97 | +`app.py`: |
| 98 | + |
| 99 | +```python |
| 100 | +def default_scheduler_adapter() -> SchedulerAdapter: |
| 101 | + if scheduler_supported(): |
| 102 | + return LaunchdSchedulerAdapter() |
| 103 | + return UnsupportedSchedulerAdapter() |
| 104 | +``` |
| 105 | + |
| 106 | +`create_services` uses `adapters.scheduler or default_scheduler_adapter()`. |
| 107 | +Tests that inject a fake scheduler are unaffected (override still wins). Cosmic |
| 108 | +Python ch.13: platform wiring belongs at the composition root, not in services. |
| 109 | + |
| 110 | +### Honest render — reflect the outcome, not the platform |
| 111 | + |
| 112 | +The setup step-builders derive "installed"/"automatic" from |
| 113 | +`result.config_update.automation[].enabled` — config *intent*, which on Linux is |
| 114 | +`true` while nothing is actually scheduled. |
| 115 | + |
| 116 | +**Design correction (found during review/tests):** render must NOT re-derive |
| 117 | +`sys.platform`. Terminal output describes *what actually happened*, and the |
| 118 | +scheduler adapter — not the OS check — is the authority on whether a job was |
| 119 | +activated. Re-checking the platform in render also broke tests that inject a |
| 120 | +working fake scheduler to exercise macOS output on a Linux host. So the seam is |
| 121 | +data, not platform: |
| 122 | + |
| 123 | +- `AutomationTaskApplyResult` gains a `scheduled: bool` field. `reconcile_task` |
| 124 | + sets it from the adapter's real result: `scheduler.install(job).installed`. |
| 125 | + The launchd adapter returns `installed=True`; the unsupported adapter returns |
| 126 | + `installed=False`. It diverges from `enabled` exactly when the platform can't |
| 127 | + schedule. |
| 128 | +- `render/setup/result.py` reads `item.scheduled`: an `enabled and not |
| 129 | + scheduled` task renders "manual — scheduling unavailable on this platform"; |
| 130 | + wiki-maintenance and the "Background Items" confirmation are driven by the |
| 131 | + *scheduled* set. When any task is `enabled and not scheduled`, a single |
| 132 | + "Scheduled automation unavailable" note is rendered. |
| 133 | +- `render/setup/background_items.py` keeps its original `len(tasks) == 0` guards |
| 134 | + (no platform check). The unavailable-notice builder is unconditional; the |
| 135 | + caller decides when to show it from result data. `platform_label()` is used |
| 136 | + only for the cosmetic OS name in the message text. |
| 137 | +- `render/automation.py` is unchanged: it already prints "not installed" per |
| 138 | + task from the real adapter status, which is accurate on every platform. |
| 139 | + |
| 140 | +Platform detection lives in exactly one place: the composition root |
| 141 | +(`default_scheduler_adapter`). Render stays platform-free. |
| 142 | + |
| 143 | +macOS path: launchd `install().installed` is `True` → `scheduled=True` → every |
| 144 | +existing branch is taken unchanged → **terminal output byte-for-byte identical |
| 145 | +on macOS** (non-negotiable per CLAUDE.md "Terminal output is behavior"). |
| 146 | + |
| 147 | +`--json` gains the additive `scheduled` field on the apply result; no existing |
| 148 | +test asserts that structure, and it does not alter prose output. |
| 149 | + |
| 150 | +## File changes |
| 151 | + |
| 152 | +| File | Change | |
| 153 | +|------|--------| |
| 154 | +| `core/platform.py` | **new** — `scheduler_supported()` | |
| 155 | +| `integrations/automation/scheduler/unsupported.py` | **new** — `UnsupportedSchedulerAdapter` | |
| 156 | +| `integrations/automation/scheduler/__init__.py` | export new adapter | |
| 157 | +| `integrations/automation/__init__.py` | export new adapter | |
| 158 | +| `app.py` | `default_scheduler_adapter()`; use it in `create_services` | |
| 159 | +| `services/automation/models.py` | add `scheduled: bool` to `AutomationTaskApplyResult` | |
| 160 | +| `services/automation/service.py` | set `scheduled` from `install().installed` | |
| 161 | +| `cli/render/setup/result.py` | steps + unavailable note driven by `scheduled` | |
| 162 | +| `cli/render/setup/background_items.py` | unconditional unavailable-notice builder | |
| 163 | +| `README.md` | supported-platforms + automation notes reflect Linux | |
| 164 | + |
| 165 | +## Test coverage |
| 166 | + |
| 167 | +New tests must sandbox `HOME` (repo convention) and force the platform rather |
| 168 | +than depend on the host, so the suite behaves identically on macOS and Linux |
| 169 | +CI. Patch `codealmanac.core.platform.scheduler_supported` (and its re-import |
| 170 | +sites) via `monkeypatch`. |
| 171 | + |
| 172 | +- **`UnsupportedSchedulerAdapter`**: `install`/`status` return not-installed, |
| 173 | + `uninstall` returns `False`, no filesystem side effects (no `Library/` |
| 174 | + created under a temp home). |
| 175 | +- **`create_app` selection**: with `scheduler_supported` patched `False`, a real |
| 176 | + `create_app()` (no scheduler override) runs `config.update` / |
| 177 | + `setup.run(--yes)` end-to-end **without raising** and exits 0; `config.toml` |
| 178 | + is written. |
| 179 | +- **No launchctl on the unsupported path**: assert the subprocess/`launchctl` |
| 180 | + entrypoint is never reached (e.g. monkeypatch `subprocess.run` to fail the |
| 181 | + test if called, or assert via the fake). |
| 182 | +- **Render**: with support forced off, setup text shows the "unavailable" note |
| 183 | + and no "Background Items" notice; with support on, existing macOS output is |
| 184 | + byte-for-byte unchanged (snapshot/substring on both branches). |
| 185 | +- Existing `test_automation_service.py` / `test_setup_service.py` continue to |
| 186 | + pass unchanged (they inject fakes / run on the real adapter via override). |
| 187 | + |
| 188 | +Gates: `uv run pytest` and `uv run ruff check .`. |
| 189 | + |
| 190 | +## Review focus (must-fix / should-fix / consider) |
| 191 | + |
| 192 | +- **must-fix**: macOS output unchanged; no launchctl invocation on non-macOS; |
| 193 | + `setup --yes` exit 0 on Linux; no partial state on the unsupported path. |
| 194 | +- **should-fix**: render no longer claims schedules are "automatic"/"installed" |
| 195 | + on Linux; platform predicate has exactly one definition. |
| 196 | +- **consider**: whether `automation status` should say "unavailable on this |
| 197 | + platform" vs. plain "not installed" (chosen: add the note — clarity). |
0 commit comments