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
12 changes: 11 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,25 @@ CASE_LOCAL=1
# Per-turn input-token ceiling for Drive chat. Default 2000000.
# CASE_TURN_TOKENS=2000000

# OpenAI context compaction threshold, in tokens. 0 = off. Default 200000.
# CASE_COMPACT_AT=200000

# Desktop resolution for new/woken computers (WxH or WxHxDEPTH).
# DESK_RESOLUTION=1280x800x24

# Browser Drive still takes the key per request (x-openai-key / x-anthropic-key).
# The box key below is only for phone chat (Telegram or ntfy).
# The box key below is used by phone chat and schedules.
# CASE_DRIVE_PROVIDER=openai
# CASE_DRIVE_API_KEY=
# CASE_DRIVE_MODEL=

# Schedule brain. CASE_BRAIN_CMD (a command template that contains {prompt})
# wins if set. Otherwise compose POSTs to CASE_BRAIN_URL, which uses the box
# key above. Outside compose, unset both and the scheduler looks for `claude`
# on PATH.
# CASE_BRAIN_URL=
# CASE_BRAIN_CMD=

# Phone chat over Telegram: make a bot with @BotFather, paste its token, start
# the ui, send /start to the bot and it replies with the chat id to put here.
# CASE_TELEGRAM_TOKEN=
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ in Drive. Cursor:
to help through Drive, Telegram, or an Assist link.
- **Skills:** the agent saves a completed task as a `SKILL.md` on the computer
and follows it next time. The file survives restarts.
- **Schedules:** recurring runs use the computer's saved identity.
- **Schedules:** recurring runs use the computer's saved identity. They use
`CASE_DRIVE_API_KEY` from `.env` (the same box key as phone chat). Set
`CASE_BRAIN_CMD` if you want a different harness.
- **Phone chat:** send tasks and answer handoffs through Telegram or ntfy.
See [phone setup](#phone-chat).

Expand Down Expand Up @@ -117,9 +119,10 @@ Drive connects out to the service, so phone chat works without exposing a local
port. Your host and Docker must stay running to receive messages and run tasks.

Phone tasks use a shared thread named `Phone`. They need a provider key on the
server because there is no browser tab to supply one. If you do not have a `.env`
file yet, copy `.env.example` to `.env` next to `compose.yaml`. Add these settings
to that file, replacing the placeholder with your key:
server because there is no browser tab to supply one. Schedules use the same
key. If you do not have a `.env` file yet, copy `.env.example` to `.env` next to
`compose.yaml`. Add these settings to that file, replacing the placeholder with
your key:

```dotenv
CASE_DRIVE_PROVIDER=openai # or anthropic
Expand Down
4 changes: 4 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ services:
CASE_BIND: "0.0.0.0"
CASE_IMAGE: ${CASE_IMAGE:-case-desk:0.1}
CASE_TOKEN: ${CASE_TOKEN:-}
CASE_BRAIN_URL: ${CASE_BRAIN_URL:-http://ui:4174/api/brain}
CASE_BRAIN_CMD: ${CASE_BRAIN_CMD:-}
CASE_MAX_RUNNING: ${CASE_MAX_RUNNING:-4}
# Total RAM the awake desktops may hold. Unset = 75% of what the engine sees,
# which on a Mac is the Docker VM, not the Mac.
Expand Down Expand Up @@ -121,6 +123,8 @@ services:
CASE_DRIVE_PROVIDER: ${CASE_DRIVE_PROVIDER:-}
CASE_DRIVE_API_KEY: ${CASE_DRIVE_API_KEY:-}
CASE_DRIVE_MODEL: ${CASE_DRIVE_MODEL:-}
CASE_TURN_TOKENS: ${CASE_TURN_TOKENS:-}
CASE_COMPACT_AT: ${CASE_COMPACT_AT:-}
CASE_TELEGRAM_TOKEN: ${CASE_TELEGRAM_TOKEN:-}
CASE_TELEGRAM_CHAT_ID: ${CASE_TELEGRAM_CHAT_ID:-}
CASE_PUBLIC_HOST: ${CASE_PUBLIC_HOST:-}
Expand Down
3 changes: 3 additions & 0 deletions control-plane/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ def _desk_resolution():
# contain {prompt}. Unset = stock claude. The template carries no --allowedTools clamp:
# a template harness runs with the box's full privileges, only use one you trust.
BRAIN_CMD = os.environ.get("CASE_BRAIN_CMD", "")
# HTTP brain (compose default): POST {computer_id, prompt} to Drive. Used only
# when BRAIN_CMD is empty. Precedence: CASE_BRAIN_CMD > CASE_BRAIN_URL > claude.
BRAIN_URL = os.environ.get("CASE_BRAIN_URL", "")
MCP_CONFIG = os.environ.get(
"CASE_MCP_CONFIG",
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "case-mcp.json"))
Expand Down
103 changes: 88 additions & 15 deletions control-plane/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@
import subprocess
import threading
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

from config import (BRAIN_BIN, BRAIN_CMD, BRAIN_TIMEOUT, MAX_RUNNING, MCP_CONFIG,
import requests

from config import (BRAIN_BIN, BRAIN_CMD, BRAIN_TIMEOUT, BRAIN_URL, MAX_RUNNING, MCP_CONFIG,
RUNS_DIR, log)
from deskclient import desk_json, screenshot_bytes
from errors import ApiError
Expand All @@ -31,20 +34,47 @@
_LOCK = threading.Lock() # guards the check-then-add on SCHED_RUNNING (sweeper vs run-now)


def compute_next(kind, spec, jitter_s):
"""Next fire time as UTC ISO. Lexicographic order == chronological (zero-padded, Z)."""
def _wall_exists(t, hh, mm):
"""False when t is a spring-forward gap (that clock time never happened)."""
back = t.astimezone(timezone.utc).astimezone(t.tzinfo)
return (back.hour, back.minute) == (hh, mm)


def compute_next(kind, spec, jitter_s, tz=None, *, now=None):
"""Next fire time as UTC ISO. Lexicographic order == chronological (zero-padded, Z).
Daily HH:MM is wall clock in tz (IANA). Empty tz = box local (MCP / old callers).
`now` overrides the clock for tests; for daily it is the wall clock in that zone."""
j = random.randint(0, int(jitter_s or 0))
if kind == "interval":
if int(spec) < 60:
raise ApiError(400, "bad_request", "interval must be at least 60 seconds")
nxt = datetime.now(timezone.utc) + timedelta(seconds=int(spec) + j)
nxt = (now or datetime.now(timezone.utc)) + timedelta(seconds=int(spec) + j)
elif kind == "daily":
local = datetime.now()
name = str(tz).strip() if tz else ""
if name:
try:
local = now or datetime.now(ZoneInfo(name))
except (ZoneInfoNotFoundError, ValueError):
raise ApiError(400, "bad_tz", f"unknown timezone {name}")
else:
# Naive on purpose: astimezone() below then picks the offset of the fire
# date, so a schedule set before a DST switch still fires at HH:MM after it.
# datetime.now().astimezone() would freeze today's offset into tomorrow.
local = now or datetime.now()
hh, mm = (int(x) for x in str(spec).split(":"))
t = local.replace(hour=hh, minute=mm, second=0, microsecond=0)
if t <= local:
t += timedelta(days=1)
nxt = (t + timedelta(seconds=j)).astimezone(timezone.utc) # naive→aware picks that date's offset
# Spring-forward gap: this HH:MM never happened that day. replace()
# still builds the invalid wall time and astimezone() shifts it (02:30
# → 03:30). Skip to the next day that actually has that clock time.
# Re-check after jitter: 01:45 + 45m can land in the same hole.
if not _wall_exists(t, hh, mm):
t += timedelta(days=1)
fired = t + timedelta(seconds=j)
if not _wall_exists(fired, fired.hour, fired.minute):
fired = t + timedelta(days=1) + timedelta(seconds=j)
nxt = fired.astimezone(timezone.utc)
else:
raise ApiError(400, "bad_kind", "kind must be 'interval' or 'daily'")
return nxt.strftime("%Y-%m-%dT%H:%M:%SZ")
Expand All @@ -67,8 +97,43 @@ def brain_argv(full_prompt):
"--allowedTools", "mcp__case__*"]


def run_brain(cid, prompt):
"""Invoke the headless brain against this computer via Case MCP. Returns (code, summary)."""
def _run_brain_url(cid, prompt, name=""):
"""POST {computer_id, prompt, name} to Drive. Returns the same (code, summary) as
the argv path, clipped the same way. `name` only titles the Drive thread."""
token = (os.environ.get("CASE_TOKEN") or "").strip()
headers = {"Authorization": f"Bearer {token}"} if token else {}
try:
r = requests.post(BRAIN_URL, json={"computer_id": cid, "prompt": prompt, "name": name},
headers=headers, timeout=BRAIN_TIMEOUT)
except requests.ConnectionError: # before Timeout: ConnectTimeout is both
return 127, (f"schedule brain unreachable at {BRAIN_URL} — start the ui service "
"or set CASE_BRAIN_CMD")
except requests.Timeout:
return -1, "brain run timed out"
except requests.RequestException as e:
return 127, f"schedule brain request failed: {e}"
try:
body = r.json() if r.content else {}
except ValueError:
body = {}
if not isinstance(body, dict):
body = {}
if r.status_code == 503:
return 2, str(body.get("error") or "schedule brain unavailable")
if r.status_code == 401:
return 1, "schedule brain rejected the token — CASE_TOKEN must match between cased and ui"
if body.get("ok"):
return (0 if body.get("finished") else 3), str(body.get("text") or "").strip()[-800:]
if body.get("error"):
return 1, str(body["error"])[-800:]
return 1, (r.text or f"HTTP {r.status_code}")[-800:]


def run_brain(cid, prompt, name=""):
"""Invoke the headless brain against this computer. Returns (code, summary).
Precedence: CASE_BRAIN_CMD > CASE_BRAIN_URL > stock claude on PATH."""
if not BRAIN_CMD and BRAIN_URL:
return _run_brain_url(cid, prompt, name)
try:
argv = brain_argv(f"On Case computer {cid}: {prompt}")
except ValueError as e:
Expand Down Expand Up @@ -134,8 +199,10 @@ def run_schedule(sid):
s = store.get_schedule(sid, enabled_only=True)
if not s:
return
# sqlite3.Row has no dict.get — index like every other column.
tz = s["tz"] if "tz" in s.keys() else None
# Reschedule FIRST so a hung/crashed run never wedges the slot.
store.set_schedule_next(sid, compute_next(s["kind"], s["spec"], s["jitter_s"]))
store.set_schedule_next(sid, compute_next(s["kind"], s["spec"], s["jitter_s"], tz))
cid, rid, started = s["computer_id"], new_id("run"), now()
code, summary, status, artifact = -1, "", "fail", None
# Only the run that woke an asleep box may put it back, never borrow a live session
Expand All @@ -145,7 +212,7 @@ def run_schedule(sid):
was_asleep = get_computer(cid)["state"] == "asleep"
do_wake(cid)
woke_for_run = was_asleep
code, summary = run_brain(cid, s["prompt"])
code, summary = run_brain(cid, s["prompt"], name=s["name"])
status = "ok" if code == 0 else "fail"
artifact = capture_run_artifacts(cid, rid, s["name"], status, summary, started) # awake
except ApiError as e:
Expand All @@ -154,6 +221,9 @@ def run_schedule(sid):
if e.code == "too_many_running":
status = "skipped"
summary = f"another computer is running (max {MAX_RUNNING} on this box)"
elif e.code == "not_enough_ram":
status = "skipped"
summary = f"not enough free RAM on this box ({e.message})"
else:
summary = f"{e.code}: {e.message}"
log.exception("schedule %s run failed", sid)
Expand Down Expand Up @@ -182,26 +252,29 @@ def fire_due_schedules(spawn):


def schedule_json(row):
return {k: row[k] for k in ("id", "computer_id", "name", "prompt", "kind", "spec",
"jitter_s", "enabled", "next_run_at", "last_run_at",
"last_status", "created_at")}
out = {k: row[k] for k in ("id", "computer_id", "name", "prompt", "kind", "spec",
"jitter_s", "enabled", "next_run_at", "last_run_at",
"last_status", "created_at")}
out["tz"] = row["tz"] if "tz" in row.keys() else None
return out


def create_schedule(cid, body):
get_computer(cid) # 404 if unknown
if "prompt" not in body or "spec" not in body:
raise ApiError(400, "bad_request", "prompt and spec are required")
kind = body.get("kind", "daily")
tz = str(body.get("tz") or "").strip() or None
try:
jitter = int(body.get("jitter_s", 300))
nxt = compute_next(kind, body["spec"], jitter) # also validates kind/spec
nxt = compute_next(kind, body["spec"], jitter, tz) # also validates kind/spec/tz
except (TypeError, ValueError):
raise ApiError(400, "bad_request",
"spec must be seconds (interval) or HH:MM (daily); "
"jitter_s must be an integer")
sid = new_id("sch")
store.insert_schedule(sid, cid, str(body.get("name") or sid), body["prompt"],
kind, str(body["spec"]), jitter, nxt)
kind, str(body["spec"]), jitter, nxt, tz)
return schedule_json(store.get_schedule(sid))


Expand Down
10 changes: 6 additions & 4 deletions control-plane/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@
CREATE TABLE IF NOT EXISTS schedules (
id TEXT PRIMARY KEY, computer_id TEXT, name TEXT, prompt TEXT,
kind TEXT, spec TEXT, jitter_s INTEGER, enabled INTEGER,
next_run_at TEXT, last_run_at TEXT, last_status TEXT, created_at TEXT
next_run_at TEXT, last_run_at TEXT, last_status TEXT, created_at TEXT,
tz TEXT
);
CREATE TABLE IF NOT EXISTS runs (
id TEXT PRIMARY KEY, schedule_id TEXT, computer_id TEXT,
Expand Down Expand Up @@ -131,6 +132,7 @@ def __init__(self, home=None):
("credentials", "probe_url", "TEXT"),
("credentials", "proof_spec", "TEXT"),
("credentials", "verification_hosts", "TEXT"),
("schedules", "tz", "TEXT"),
]

# Active (non-terminal) auth-attempt statuses, kept here so the partial unique
Expand Down Expand Up @@ -541,10 +543,10 @@ def prune_expired_assist_tokens(self):
(ts, ts)).rowcount

# ---- schedules ----
def insert_schedule(self, sid, cid, name, prompt, kind, spec, jitter_s, next_run_at):
def insert_schedule(self, sid, cid, name, prompt, kind, spec, jitter_s, next_run_at, tz=None):
self.q("INSERT INTO schedules (id,computer_id,name,prompt,kind,spec,jitter_s,enabled,"
"next_run_at,last_run_at,last_status,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(sid, cid, name, prompt, kind, spec, jitter_s, 1, next_run_at, None, None, now()))
"next_run_at,last_run_at,last_status,created_at,tz) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
(sid, cid, name, prompt, kind, spec, jitter_s, 1, next_run_at, None, None, now(), tz))

def get_schedule(self, sid, enabled_only=False):
if enabled_only:
Expand Down
10 changes: 7 additions & 3 deletions mcp/case_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -601,12 +601,16 @@ def handoff_get(handoff_id: str) -> dict:
if os.environ.get("CASE_MCP_SCHEDULES") == "1":
@mcp.tool()
def schedule_create(computer_id: str, prompt: str, kind: str = "daily",
spec: str = "09:00", name: str = "", jitter_s: int = 300) -> dict:
"""Create a recurring schedule on a computer. kind=daily (spec HH:MM local) or
interval (spec seconds as string). Fires unattended using the host brain credential."""
spec: str = "09:00", name: str = "", jitter_s: int = 300,
tz: str = "") -> dict:
"""Create a recurring schedule on a computer. kind=daily (spec HH:MM in tz,
or box local if tz is empty) or interval (spec seconds as string). Fires
unattended using the host brain credential."""
body = {"prompt": prompt, "kind": kind, "spec": spec, "jitter_s": jitter_s}
if name:
body["name"] = name
if tz:
body["tz"] = tz
return call("POST", f"/computers/{computer_id}/schedules", json=body).json()


Expand Down
Loading
Loading