Skip to content
Merged
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
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: CI

on:
push:
branches: [main, dev-*]
pull_request:
branches: [main, dev-*]

jobs:
lint-and-test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: cairn

steps:
- uses: actions/checkout@v4

- uses: astral-sh/setup-uv@v6
with:
enable-cache: true

- name: Install dependencies
run: uv sync --group dev

- name: Ruff check
run: uv run ruff check src/

- name: Ruff format
run: uv run ruff format --check src/

- name: Pyright
run: uv run pyright src/

- name: Tests
run: uv run pytest
40 changes: 40 additions & 0 deletions cairn/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,51 @@ build-backend = "uv_build"
dev = [
"httpx>=0.27",
"pytest>=8.3",
"ruff>=0.12",
"pyright>=1.1",
]

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.ruff]
target-version = "py312"
line-length = 120

[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"N", # pep8-naming
"UP", # pyupgrade
"B", # flake8-bugbear
"S", # flake8-bandit (security)
"A", # flake8-builtins
"T20", # flake8-print
"SIM", # flake8-simplify
"RUF", # ruff-specific
]
ignore = [
"E501", # line too long — log/SQL lines are fine
"S101", # assert used — tests need it
"S108", # hardcoded tmp path — intentional container paths
"S311", # pseudo-random — used for load balancing, not crypto
"S603", # subprocess without shell=True — intentional
"S607", # partial executable path — intentional
"A002", # shadowing builtin — query param naming
"SIM108", # ternary — readability preference
]

[tool.ruff.lint.isort]
known-first-party = ["cairn"]

[tool.pyright]
pythonVersion = "3.12"
typeCheckingMode = "basic"
include = ["src"]

[[tool.uv.index]]
url = "https://mirrors.aliyun.com/pypi/simple"
default = true
33 changes: 20 additions & 13 deletions cairn/src/cairn/dispatcher/config.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
from __future__ import annotations

from decimal import Decimal, InvalidOperation
import json
from decimal import Decimal, InvalidOperation
from importlib import resources
from pathlib import Path
from typing import Any, Literal

import yaml
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator


TaskType = Literal["reason", "explore", "bootstrap"]
WorkerType = Literal["claudecode", "codex", "pi", "mock"]
CompletedAction = Literal["remove", "stop"]
Expand Down Expand Up @@ -122,9 +121,7 @@
},
}

MOCK_ALLOWED_ENV_KEYS = frozenset(
{f"MOCK_{phase.upper()}" for phase in MOCK_ALLOWED_OUTCOMES}
)
MOCK_ALLOWED_ENV_KEYS = frozenset({f"MOCK_{phase.upper()}" for phase in MOCK_ALLOWED_OUTCOMES})


class ReasonTaskConfig(BaseModel):
Expand Down Expand Up @@ -185,7 +182,7 @@ def validate_task_types(cls, value: list[TaskType]) -> list[TaskType]:
return value

@model_validator(mode="after")
def validate_env(self) -> "WorkerConfig":
def validate_env(self) -> WorkerConfig:
required = WORKER_ENV_KEYS[self.type]
missing = [key for key in required if not self.env.get(key)]
if missing:
Expand Down Expand Up @@ -238,7 +235,7 @@ def merge_common_env(cls, data: Any) -> Any:
return merged

@model_validator(mode="after")
def validate_workers(self) -> "DispatchConfig":
def validate_workers(self) -> DispatchConfig:
names = [worker.name for worker in self.workers]
if len(set(names)) != len(names):
raise ValueError("worker names must be unique")
Expand All @@ -249,7 +246,7 @@ def validate_workers(self) -> "DispatchConfig":
return self

@classmethod
def load(cls, path: Path) -> "DispatchConfig":
def load(cls, path: Path) -> DispatchConfig:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
config = cls.model_validate(data)
validate_prompt_resources(config.runtime.prompt_group)
Expand Down Expand Up @@ -301,7 +298,9 @@ def resolve_mock_behavior(worker_name: str, env: dict[str, str]) -> dict[str, di
raise ValueError(f"worker {worker_name} {prefix}.outcomes must be an object")
unknown_outcomes = sorted(set(raw_outcomes) - allowed_outcomes)
if unknown_outcomes:
raise ValueError(f"worker {worker_name} {prefix}.outcomes has unsupported keys: {', '.join(unknown_outcomes)}")
raise ValueError(
f"worker {worker_name} {prefix}.outcomes has unsupported keys: {', '.join(unknown_outcomes)}"
)
outcomes: dict[str, float] = {}
total = Decimal("0")
for outcome in sorted(allowed_outcomes):
Expand Down Expand Up @@ -336,17 +335,23 @@ def resolve_mock_behavior(worker_name: str, env: dict[str, str]) -> dict[str, di
if "fact_ids_gte" in rule:
value = rule["fact_ids_gte"]
if not isinstance(value, int) or value < 0:
raise ValueError(f"worker {worker_name} {prefix}.rules[{index}].fact_ids_gte must be a non-negative integer")
raise ValueError(
f"worker {worker_name} {prefix}.rules[{index}].fact_ids_gte must be a non-negative integer"
)
entry["fact_ids_gte"] = value
if "fact_ids_lte" in rule:
value = rule["fact_ids_lte"]
if not isinstance(value, int) or value < 0:
raise ValueError(f"worker {worker_name} {prefix}.rules[{index}].fact_ids_lte must be a non-negative integer")
raise ValueError(
f"worker {worker_name} {prefix}.rules[{index}].fact_ids_lte must be a non-negative integer"
)
entry["fact_ids_lte"] = value
if "open_intents_empty" in rule:
value = rule["open_intents_empty"]
if not isinstance(value, bool):
raise ValueError(f"worker {worker_name} {prefix}.rules[{index}].open_intents_empty must be boolean")
raise ValueError(
f"worker {worker_name} {prefix}.rules[{index}].open_intents_empty must be boolean"
)
entry["open_intents_empty"] = value
normalized_rules.append(entry)
behavior[phase]["rules"] = normalized_rules
Expand All @@ -357,7 +362,9 @@ def _mock_env_prefix(phase: str) -> str:
return f"MOCK_{phase.upper()}"


def _parse_mock_phase_payload(worker_name: str, env: dict[str, str], key: str, default: dict[str, Any]) -> dict[str, Any]:
def _parse_mock_phase_payload(
worker_name: str, env: dict[str, str], key: str, default: dict[str, Any]
) -> dict[str, Any]:
raw = env.get(key)
if raw is None:
return json.loads(json.dumps(default))
Expand Down
4 changes: 3 additions & 1 deletion cairn/src/cairn/dispatcher/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ def _looks_like_explore_data(payload: dict[str, Any]) -> bool:


def validate_reason_payload(
payload: dict[str, Any], open_intents_empty: bool, max_intents: int,
payload: dict[str, Any],
open_intents_empty: bool,
max_intents: int,
) -> tuple[str, dict[str, Any] | list[dict[str, Any]] | None]:
accepted, data = _unwrap_wrapped_payload(payload)
if accepted is False:
Expand Down
4 changes: 2 additions & 2 deletions cairn/src/cairn/dispatcher/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ class DispatcherLogFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
name = record.name
if name.startswith(self._PREFIX):
shortname = name[len(self._PREFIX):]
shortname = name[len(self._PREFIX) :]
else:
shortname = name
setattr(record, "shortname", shortname)
record.shortname = shortname
return super().format(record)


Expand Down
1 change: 0 additions & 1 deletion cairn/src/cairn/dispatcher/output_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import re
from typing import Any


FENCED_BLOCK_RE = re.compile(r"```(?:json)?\s*\n?(.*?)```", re.IGNORECASE | re.DOTALL)


Expand Down
8 changes: 4 additions & 4 deletions cairn/src/cairn/dispatcher/protocol/client.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Any
import logging
import threading
from dataclasses import dataclass
from typing import Any

from pydantic import TypeAdapter
import requests
from pydantic import TypeAdapter
from requests.adapters import HTTPAdapter

from cairn.server.models import Intent, ProjectDetail, ProjectSummary, Settings
from cairn.server.models import ProjectDetail, ProjectSummary, Settings

LOG = logging.getLogger(__name__)

Expand Down
6 changes: 4 additions & 2 deletions cairn/src/cairn/dispatcher/runtime/containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

import io
import logging
from pathlib import PurePosixPath
import tarfile
import threading
import uuid
from pathlib import PurePosixPath

import docker
from docker.errors import APIError, DockerException, NotFound
Expand Down Expand Up @@ -173,7 +173,9 @@ def managed_container_names(self) -> list[str]:
except DockerException as exc:
LOG.warning("failed to list managed containers error=%s", exc)
return []
return sorted(container.name for container in containers if container.name.startswith(self._PREFIX))
return sorted(
container.name for container in containers if container.name and container.name.startswith(self._PREFIX)
)

def needs_completed_cleanup(self, project_id: str) -> bool:
name = self.container_name(project_id)
Expand Down
5 changes: 2 additions & 3 deletions cairn/src/cairn/dispatcher/runtime/heartbeat.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from cairn.dispatcher.protocol.client import ApiResult, CairnClient
from cairn.dispatcher.runtime.process import ManagedProcess


LOG = logging.getLogger(__name__)
HEARTBEAT_FAILURE_GRACE_MULTIPLIER = 2

Expand Down Expand Up @@ -47,7 +46,7 @@ def for_intent(
intent_id: str,
worker_name: str,
interval: int,
) -> "HeartbeatLease":
) -> HeartbeatLease:
return cls(
heartbeat=lambda: client.heartbeat(project_id, intent_id, worker_name),
scope=f"project={project_id} intent={intent_id}",
Expand All @@ -62,7 +61,7 @@ def for_reason(
project_id: str,
worker_name: str,
interval: int,
) -> "HeartbeatLease":
) -> HeartbeatLease:
return cls(
heartbeat=lambda: client.reason_heartbeat(project_id, worker_name),
scope=f"project={project_id} reason",
Expand Down
10 changes: 6 additions & 4 deletions cairn/src/cairn/dispatcher/runtime/process.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from __future__ import annotations

from contextlib import suppress
from dataclasses import dataclass
import logging
import threading
import time
from contextlib import suppress
from dataclasses import dataclass
from typing import Any

from docker.errors import APIError, DockerException
Expand All @@ -29,7 +29,7 @@ def __init__(self, container: Container, command: list[str], env: dict[str, str]
self.command = command
self.env = env
self._container = container
self._api = container.client.api
self._api = container.client.api # type: ignore[union-attr]
self._exec_id: str | None = None
self._reader: threading.Thread | None = None
self._stdout: list[str] = []
Expand Down Expand Up @@ -169,7 +169,9 @@ def _kill_pid(self, pid: int) -> None:
if exit_code in (None, 0, 1):
return
if last_error is not None:
LOG.warning("failed to kill container exec pid=%s container=%s error=%s", pid, self._container.name, last_error)
LOG.warning(
"failed to kill container exec pid=%s container=%s error=%s", pid, self._container.name, last_error
)

@staticmethod
def _split_chunk(chunk: Any) -> tuple[str, str]:
Expand Down
4 changes: 1 addition & 3 deletions cairn/src/cairn/dispatcher/runtime/startup_healthcheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,7 @@ def _log_report(results: list[StartupHealthcheckResult], *, show_commands: bool)
f"{duration_seconds:>8} "
f"{preview}"
)
lines.append(
f"[=] Summary: total={len(results)} healthy={healthy_count} unhealthy={len(results) - healthy_count}"
)
lines.append(f"[=] Summary: total={len(results)} healthy={healthy_count} unhealthy={len(results) - healthy_count}")
if show_commands:
lines.append("")
lines.append("[=] Startup healthcheck commands")
Expand Down
Loading
Loading