Skip to content
Open
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
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ shaped the way it is, what broke before, which invariants matter, and how
workflows cross files and services. The wiki is plain markdown in your repo,
indexed locally, and reviewed in Git like any other code change.

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

## Quickstart

Expand Down Expand Up @@ -57,8 +59,9 @@ codealmanac setup --yes
codealmanac setup --yes --runner claude
```

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

| Job | Default schedule | What it does |
| --- | ---: | --- |
Expand Down
197 changes: 197 additions & 0 deletions docs/plans/2026-07-24-linux-support-option-a.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# Linux support — Option A: graceful automation skip on non-macOS

## Problem

Every lifecycle and read command already runs on Linux — `init`, `ingest`,
`garden`, `search`, indexing, the harness, telemetry are all platform-neutral
Python. The single blocker is **scheduled automation**. `create_app` wires
`AutomationService` with `LaunchdSchedulerAdapter`, which shells out to
`launchctl`. On Linux `launchctl` does not exist, so:

- `codealmanac setup --yes` crashes with
`launchctl bootstrap failed for com.codealmanac.sync: [Errno 2] ... 'launchctl'`
and exits 1 (issue #31).
- `config set automation.*` hits the same path (both routes run
`AutomationService.reconcile_task` → `scheduler.install`).
- `config.toml` is written *before* the scheduler runs, so the crash leaves a
half-configured global state.

This is **not** a rewrite. The `SchedulerAdapter` port already exists
(`services/automation/ports.py`, 3 methods) and `create_app` already accepts a
`scheduler` override. Launchd is just one implementation of that port.

## Scope (Option A)

Make the whole product usable on Linux **today** by degrading scheduled
automation gracefully instead of crashing. Scheduled background runs are not
provided on Linux in this slice; the user runs `codealmanac sync` / `garden`
manually (or wires their own cron). Real Linux scheduling (systemd user timers)
is **Option B**, a separate slice.

### In scope

1. Platform capability predicate in `core/` — single source of truth.
2. `UnsupportedSchedulerAdapter` implementing the existing port as a no-op.
3. Composition root selects the adapter by platform (launchd on macOS,
unsupported elsewhere). Only branch point for the platform fact in wiring.
4. Setup and automation-status render tell the user clearly, on non-macOS, that
scheduled automation is unavailable — and stop claiming schedules are
"installed"/"automatic" when nothing was scheduled.
5. Suppress the macOS-only "Background Items Added" heads-up off-macOS.

### Out of scope

- systemd / cron backends (Option B).
- Renaming `plist_path` / `Library/LaunchAgents` in the shared model (Option B
cleanup — the null adapter never touches those paths, so it can wait).
- Forcing `automation.*.enabled = false` in config on Linux. We keep config as
recorded intent so Option B activates it later without a re-run. Render, not
config, is where we tell the truth about what actually got scheduled.
- README / docs edits (fold into the commit if trivial; not gated here).

## Design

### Single platform predicate

New `src/codealmanac/core/platform.py`:

```python
import sys

def scheduler_supported() -> bool:
"""True when this platform has a scheduler backend CodeAlmanac can drive.

Today the only backend is macOS launchd. Option B (systemd user timers)
will widen this. Consulted at the composition root (adapter selection) and
in render (what to tell the user about scheduling).
"""
return sys.platform == "darwin"
```

One predicate, two honest consult sites (wiring + render). This is dependency
injection + presentation, not a scattered special-case: there is exactly one
definition of "can we schedule here".

### Null adapter

New `src/codealmanac/integrations/automation/scheduler/unsupported.py`:

```python
class UnsupportedSchedulerAdapter:
def install(self, job): return _not_installed(job)
def uninstall(self, job): return False
def status(self, job): return _not_installed(job)
```

`_not_installed` returns `ScheduledJobStatus(installed=False, loaded=False)`.
No filesystem writes, no `Library/LaunchAgents` dirs, no subprocess. Because
`install()` no longer raises, `setup` completes and exits 0 — which also
**removes the half-configured-state bug on Linux** (nothing fails, so nothing
is left partial). `reconcile_task` already ignores the `install()` return value.

Export it from `integrations/automation/scheduler/__init__.py` and
`integrations/automation/__init__.py`.

### Composition root selects the adapter

`app.py`:

```python
def default_scheduler_adapter() -> SchedulerAdapter:
if scheduler_supported():
return LaunchdSchedulerAdapter()
return UnsupportedSchedulerAdapter()
```

`create_services` uses `adapters.scheduler or default_scheduler_adapter()`.
Tests that inject a fake scheduler are unaffected (override still wins). Cosmic
Python ch.13: platform wiring belongs at the composition root, not in services.

### Honest render — reflect the outcome, not the platform

The setup step-builders derive "installed"/"automatic" from
`result.config_update.automation[].enabled` — config *intent*, which on Linux is
`true` while nothing is actually scheduled.

**Design correction (found during review/tests):** render must NOT re-derive
`sys.platform`. Terminal output describes *what actually happened*, and the
scheduler adapter — not the OS check — is the authority on whether a job was
activated. Re-checking the platform in render also broke tests that inject a
working fake scheduler to exercise macOS output on a Linux host. So the seam is
data, not platform:

- `AutomationTaskApplyResult` gains a `scheduled: bool` field. `reconcile_task`
sets it from the adapter's real result: `scheduler.install(job).installed`.
The launchd adapter returns `installed=True`; the unsupported adapter returns
`installed=False`. It diverges from `enabled` exactly when the platform can't
schedule.
- `render/setup/result.py` reads `item.scheduled`: an `enabled and not
scheduled` task renders "manual — scheduling unavailable on this platform";
wiki-maintenance and the "Background Items" confirmation are driven by the
*scheduled* set. When any task is `enabled and not scheduled`, a single
"Scheduled automation unavailable" note is rendered.
- `render/setup/background_items.py` keeps its original `len(tasks) == 0` guards
(no platform check). The unavailable-notice builder is unconditional; the
caller decides when to show it from result data. `platform_label()` is used
only for the cosmetic OS name in the message text.
- `render/automation.py` is unchanged: it already prints "not installed" per
task from the real adapter status, which is accurate on every platform.

Platform detection lives in exactly one place: the composition root
(`default_scheduler_adapter`). Render stays platform-free.

macOS path: launchd `install().installed` is `True` → `scheduled=True` → every
existing branch is taken unchanged → **terminal output byte-for-byte identical
on macOS** (non-negotiable per CLAUDE.md "Terminal output is behavior").

`--json` gains the additive `scheduled` field on the apply result; no existing
test asserts that structure, and it does not alter prose output.

## File changes

| File | Change |
|------|--------|
| `core/platform.py` | **new** — `scheduler_supported()` |
| `integrations/automation/scheduler/unsupported.py` | **new** — `UnsupportedSchedulerAdapter` |
| `integrations/automation/scheduler/__init__.py` | export new adapter |
| `integrations/automation/__init__.py` | export new adapter |
| `app.py` | `default_scheduler_adapter()`; use it in `create_services` |
| `services/automation/models.py` | add `scheduled: bool` to `AutomationTaskApplyResult` |
| `services/automation/service.py` | set `scheduled` from `install().installed` |
| `cli/render/setup/result.py` | steps + unavailable note driven by `scheduled` |
| `cli/render/setup/background_items.py` | unconditional unavailable-notice builder |
| `README.md` | supported-platforms + automation notes reflect Linux |

## Test coverage

New tests must sandbox `HOME` (repo convention) and force the platform rather
than depend on the host, so the suite behaves identically on macOS and Linux
CI. Patch `codealmanac.core.platform.scheduler_supported` (and its re-import
sites) via `monkeypatch`.

- **`UnsupportedSchedulerAdapter`**: `install`/`status` return not-installed,
`uninstall` returns `False`, no filesystem side effects (no `Library/`
created under a temp home).
- **`create_app` selection**: with `scheduler_supported` patched `False`, a real
`create_app()` (no scheduler override) runs `config.update` /
`setup.run(--yes)` end-to-end **without raising** and exits 0; `config.toml`
is written.
- **No launchctl on the unsupported path**: assert the subprocess/`launchctl`
entrypoint is never reached (e.g. monkeypatch `subprocess.run` to fail the
test if called, or assert via the fake).
- **Render**: with support forced off, setup text shows the "unavailable" note
and no "Background Items" notice; with support on, existing macOS output is
byte-for-byte unchanged (snapshot/substring on both branches).
- Existing `test_automation_service.py` / `test_setup_service.py` continue to
pass unchanged (they inject fakes / run on the real adapter via override).

Gates: `uv run pytest` and `uv run ruff check .`.

## Review focus (must-fix / should-fix / consider)

- **must-fix**: macOS output unchanged; no launchctl invocation on non-macOS;
`setup --yes` exit 0 on Linux; no partial state on the unsupported path.
- **should-fix**: render no longer claims schedules are "automatic"/"installed"
on Linux; platform predicate has exactly one definition.
- **consider**: whether `automation status` should say "unavailable on this
platform" vs. plain "not installed" (chosen: add the note — clarity).
14 changes: 12 additions & 2 deletions src/codealmanac/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
from dataclasses import dataclass

from codealmanac import __version__
from codealmanac.integrations.automation import LaunchdSchedulerAdapter
from codealmanac.core.platform import scheduler_supported
from codealmanac.integrations.automation import (
LaunchdSchedulerAdapter,
UnsupportedSchedulerAdapter,
)
from codealmanac.integrations.harnesses import default_harness_adapters
from codealmanac.integrations.runs import (
PsutilRunProcessController,
Expand Down Expand Up @@ -194,12 +198,18 @@ def create_app(
return assemble_app(services, workflows)


def default_scheduler_adapter() -> SchedulerAdapter:
if scheduler_supported():
return LaunchdSchedulerAdapter()
return UnsupportedSchedulerAdapter()


def create_services(
local_state: LocalStatePaths,
adapters: AppAdapters,
) -> Services:
repositories = RepositoriesService(RepositoryStore(local_state.database_path))
automation = AutomationService(adapters.scheduler or LaunchdSchedulerAdapter())
automation = AutomationService(adapters.scheduler or default_scheduler_adapter())
config_service = ConfigService(
ConfigStore(),
local_state.config_path,
Expand Down
12 changes: 12 additions & 0 deletions src/codealmanac/cli/render/setup/background_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from codealmanac.cli.render.brand import BAR, BLUE, RST, WHITE_BOLD
from codealmanac.cli.render.terminal import wrap_with_prefixes, write_line
from codealmanac.core.platform import platform_label
from codealmanac.services.automation.models import AutomationTask


Expand Down Expand Up @@ -80,6 +81,17 @@ def background_item_confirmation_notice(
)


def scheduler_unavailable_notice() -> BackgroundItemNotice:
return BackgroundItemNotice(
title="Scheduled automation unavailable",
lines=(
f"CodeAlmanac can't schedule background jobs on {platform_label()} yet.",
"Run codealmanac sync and codealmanac garden manually, or wire them "
"into your own cron/systemd timer.",
),
)


def automation_task_names(tasks: tuple[AutomationTask, ...]) -> str:
names = tuple(
{
Expand Down
25 changes: 21 additions & 4 deletions src/codealmanac/cli/render/setup/result.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from codealmanac.cli.render.setup.background_items import (
background_item_confirmation_notice,
render_background_item_notice,
scheduler_unavailable_notice,
)
from codealmanac.cli.render.setup.steps import SetupStep, render_setup_step
from codealmanac.cli.render.setup.uninstall import render_uninstall_text
Expand Down Expand Up @@ -55,17 +56,29 @@ def render_setup_text(result: SetupResult) -> None:
if index < len(steps) - 1:
write_line(BAR)
background_notice = background_item_confirmation_notice(
enabled_automation_tasks(result)
scheduled_automation_tasks(result)
)
if background_notice is not None:
write_line(BAR)
render_background_item_notice(background_notice)
if automation_scheduling_unavailable(result):
write_line(BAR)
render_background_item_notice(scheduler_unavailable_notice())
write_line("")
render_next_steps_box(next_step_lines(result))


def enabled_automation_tasks(result: SetupResult) -> tuple[AutomationTask, ...]:
return tuple(item.task for item in result.config_update.automation if item.enabled)
def scheduled_automation_tasks(result: SetupResult) -> tuple[AutomationTask, ...]:
return tuple(
item.task for item in result.config_update.automation if item.scheduled
)


def automation_scheduling_unavailable(result: SetupResult) -> bool:
return any(
item.enabled and not item.scheduled
for item in result.config_update.automation
)


def next_step_lines(result: SetupResult) -> tuple[str, ...]:
Expand Down Expand Up @@ -155,6 +168,8 @@ def automation_step(result: SetupResult, task: AutomationTask, label: str) -> Se
item = applied.get(task)
if item is None:
return SetupStep(label, "skipped", "not requested")
if item.enabled and not item.scheduled:
return SetupStep(label, "manual", "scheduling unavailable on this platform")
if item.enabled:
return SetupStep(label, "installed", installed_automation_detail(result, task))
return SetupStep(label, "disabled", disabled_automation_detail(task))
Expand All @@ -163,7 +178,9 @@ def automation_step(result: SetupResult, task: AutomationTask, label: str) -> Se
def wiki_maintenance_step(result: SetupResult) -> SetupStep:
if len(result.config_update.automation) == 0:
return SetupStep("Wiki maintenance", "manual", "no schedules installed")
installed = {item.task for item in result.config_update.automation if item.enabled}
installed = {
item.task for item in result.config_update.automation if item.scheduled
}
if AutomationTask.SYNC in installed and AutomationTask.GARDEN in installed:
return SetupStep(
"Wiki maintenance",
Expand Down
17 changes: 17 additions & 0 deletions src/codealmanac/core/platform.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import platform
import sys


def scheduler_supported() -> bool:
"""True when this platform has a scheduler backend CodeAlmanac can drive.

Today the only backend is macOS launchd. Option B (systemd user timers)
will widen this. Consulted at the composition root to pick the scheduler
adapter, and in render to decide what to tell the user about scheduling.
"""
return sys.platform == "darwin"


def platform_label() -> str:
"""Human-facing OS name for messaging, e.g. "Linux"."""
return platform.system() or sys.platform
7 changes: 5 additions & 2 deletions src/codealmanac/integrations/automation/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from codealmanac.integrations.automation.scheduler import LaunchdSchedulerAdapter
from codealmanac.integrations.automation.scheduler import (
LaunchdSchedulerAdapter,
UnsupportedSchedulerAdapter,
)

__all__ = ["LaunchdSchedulerAdapter"]
__all__ = ["LaunchdSchedulerAdapter", "UnsupportedSchedulerAdapter"]
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from codealmanac.integrations.automation.scheduler.launchd import (
LaunchdSchedulerAdapter,
)
from codealmanac.integrations.automation.scheduler.unsupported import (
UnsupportedSchedulerAdapter,
)

__all__ = ["LaunchdSchedulerAdapter"]
__all__ = ["LaunchdSchedulerAdapter", "UnsupportedSchedulerAdapter"]
Loading