Skip to content

Commit 1e2cd48

Browse files
committed
feat: graceful automation skip on non-macOS (Linux support, Option A)
Select the scheduler adapter at the composition root: launchd on macOS, a no-op UnsupportedSchedulerAdapter elsewhere. setup/config no longer crash on launchctl off macOS; render reports scheduling unavailable from the adapter's actual outcome via a new AutomationTaskApplyResult.scheduled field.
1 parent 0f15350 commit 1e2cd48

12 files changed

Lines changed: 474 additions & 13 deletions

File tree

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ shaped the way it is, what broke before, which invariants matter, and how
2121
workflows cross files and services. The wiki is plain markdown in your repo,
2222
indexed locally, and reviewed in Git like any other code change.
2323

24-
**Supported today:** macOS with Codex or Claude Code. Requires Python 3.12+.
24+
**Supported today:** macOS and Linux with Codex or Claude Code. Requires Python
25+
3.12+. Scheduled background automation (`launchd`) is macOS-only for now; on
26+
Linux everything else works and you run `codealmanac sync` / `garden` yourself.
2527

2628
## Quickstart
2729

@@ -57,8 +59,9 @@ codealmanac setup --yes
5759
codealmanac setup --yes --runner claude
5860
```
5961

60-
Setup installs agent instructions for your chosen tools and three local macOS
61-
`launchd` jobs. The jobs and all wiki work run locally.
62+
Setup installs agent instructions for your chosen tools and, on macOS, three
63+
local `launchd` jobs. On Linux the schedules are skipped (setup still completes)
64+
and you trigger `sync` / `garden` manually. All wiki work runs locally.
6265

6366
| Job | Default schedule | What it does |
6467
| --- | ---: | --- |
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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).

src/codealmanac/app.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
from dataclasses import dataclass
33

44
from codealmanac import __version__
5-
from codealmanac.integrations.automation import LaunchdSchedulerAdapter
5+
from codealmanac.core.platform import scheduler_supported
6+
from codealmanac.integrations.automation import (
7+
LaunchdSchedulerAdapter,
8+
UnsupportedSchedulerAdapter,
9+
)
610
from codealmanac.integrations.harnesses import default_harness_adapters
711
from codealmanac.integrations.runs import (
812
PsutilRunProcessController,
@@ -194,12 +198,18 @@ def create_app(
194198
return assemble_app(services, workflows)
195199

196200

201+
def default_scheduler_adapter() -> SchedulerAdapter:
202+
if scheduler_supported():
203+
return LaunchdSchedulerAdapter()
204+
return UnsupportedSchedulerAdapter()
205+
206+
197207
def create_services(
198208
local_state: LocalStatePaths,
199209
adapters: AppAdapters,
200210
) -> Services:
201211
repositories = RepositoriesService(RepositoryStore(local_state.database_path))
202-
automation = AutomationService(adapters.scheduler or LaunchdSchedulerAdapter())
212+
automation = AutomationService(adapters.scheduler or default_scheduler_adapter())
203213
config_service = ConfigService(
204214
ConfigStore(),
205215
local_state.config_path,

src/codealmanac/cli/render/setup/background_items.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from codealmanac.cli.render.brand import BAR, BLUE, RST, WHITE_BOLD
44
from codealmanac.cli.render.terminal import wrap_with_prefixes, write_line
5+
from codealmanac.core.platform import platform_label
56
from codealmanac.services.automation.models import AutomationTask
67

78

@@ -80,6 +81,17 @@ def background_item_confirmation_notice(
8081
)
8182

8283

84+
def scheduler_unavailable_notice() -> BackgroundItemNotice:
85+
return BackgroundItemNotice(
86+
title="Scheduled automation unavailable",
87+
lines=(
88+
f"CodeAlmanac can't schedule background jobs on {platform_label()} yet.",
89+
"Run codealmanac sync and codealmanac garden manually, or wire them "
90+
"into your own cron/systemd timer.",
91+
),
92+
)
93+
94+
8395
def automation_task_names(tasks: tuple[AutomationTask, ...]) -> str:
8496
names = tuple(
8597
{

src/codealmanac/cli/render/setup/result.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from codealmanac.cli.render.setup.background_items import (
1212
background_item_confirmation_notice,
1313
render_background_item_notice,
14+
scheduler_unavailable_notice,
1415
)
1516
from codealmanac.cli.render.setup.steps import SetupStep, render_setup_step
1617
from codealmanac.cli.render.setup.uninstall import render_uninstall_text
@@ -55,17 +56,29 @@ def render_setup_text(result: SetupResult) -> None:
5556
if index < len(steps) - 1:
5657
write_line(BAR)
5758
background_notice = background_item_confirmation_notice(
58-
enabled_automation_tasks(result)
59+
scheduled_automation_tasks(result)
5960
)
6061
if background_notice is not None:
6162
write_line(BAR)
6263
render_background_item_notice(background_notice)
64+
if automation_scheduling_unavailable(result):
65+
write_line(BAR)
66+
render_background_item_notice(scheduler_unavailable_notice())
6367
write_line("")
6468
render_next_steps_box(next_step_lines(result))
6569

6670

67-
def enabled_automation_tasks(result: SetupResult) -> tuple[AutomationTask, ...]:
68-
return tuple(item.task for item in result.config_update.automation if item.enabled)
71+
def scheduled_automation_tasks(result: SetupResult) -> tuple[AutomationTask, ...]:
72+
return tuple(
73+
item.task for item in result.config_update.automation if item.scheduled
74+
)
75+
76+
77+
def automation_scheduling_unavailable(result: SetupResult) -> bool:
78+
return any(
79+
item.enabled and not item.scheduled
80+
for item in result.config_update.automation
81+
)
6982

7083

7184
def next_step_lines(result: SetupResult) -> tuple[str, ...]:
@@ -155,6 +168,8 @@ def automation_step(result: SetupResult, task: AutomationTask, label: str) -> Se
155168
item = applied.get(task)
156169
if item is None:
157170
return SetupStep(label, "skipped", "not requested")
171+
if item.enabled and not item.scheduled:
172+
return SetupStep(label, "manual", "scheduling unavailable on this platform")
158173
if item.enabled:
159174
return SetupStep(label, "installed", installed_automation_detail(result, task))
160175
return SetupStep(label, "disabled", disabled_automation_detail(task))
@@ -163,7 +178,9 @@ def automation_step(result: SetupResult, task: AutomationTask, label: str) -> Se
163178
def wiki_maintenance_step(result: SetupResult) -> SetupStep:
164179
if len(result.config_update.automation) == 0:
165180
return SetupStep("Wiki maintenance", "manual", "no schedules installed")
166-
installed = {item.task for item in result.config_update.automation if item.enabled}
181+
installed = {
182+
item.task for item in result.config_update.automation if item.scheduled
183+
}
167184
if AutomationTask.SYNC in installed and AutomationTask.GARDEN in installed:
168185
return SetupStep(
169186
"Wiki maintenance",

src/codealmanac/core/platform.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import platform
2+
import sys
3+
4+
5+
def scheduler_supported() -> bool:
6+
"""True when this platform has a scheduler backend CodeAlmanac can drive.
7+
8+
Today the only backend is macOS launchd. Option B (systemd user timers)
9+
will widen this. Consulted at the composition root to pick the scheduler
10+
adapter, and in render to decide what to tell the user about scheduling.
11+
"""
12+
return sys.platform == "darwin"
13+
14+
15+
def platform_label() -> str:
16+
"""Human-facing OS name for messaging, e.g. "Linux"."""
17+
return platform.system() or sys.platform
Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1-
from codealmanac.integrations.automation.scheduler import LaunchdSchedulerAdapter
1+
from codealmanac.integrations.automation.scheduler import (
2+
LaunchdSchedulerAdapter,
3+
UnsupportedSchedulerAdapter,
4+
)
25

3-
__all__ = ["LaunchdSchedulerAdapter"]
6+
__all__ = ["LaunchdSchedulerAdapter", "UnsupportedSchedulerAdapter"]
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from codealmanac.integrations.automation.scheduler.launchd import (
22
LaunchdSchedulerAdapter,
33
)
4+
from codealmanac.integrations.automation.scheduler.unsupported import (
5+
UnsupportedSchedulerAdapter,
6+
)
47

5-
__all__ = ["LaunchdSchedulerAdapter"]
8+
__all__ = ["LaunchdSchedulerAdapter", "UnsupportedSchedulerAdapter"]

0 commit comments

Comments
 (0)