OpenWorker: initial import

Imported from andrewyng/aisuite@1b4bbf303e
(contents of its platform/ directory, hoisted to the repo root).
Development history prior to this commit lives in that repository.

Co-authored-by: Devika <devikaverma11@gmail.com>
This commit is contained in:
Rohit C Prasad
2026-07-21 11:09:41 -07:00
co-authored by Devika
commit 2b45018ffa
413 changed files with 93539 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
"""Automation — scheduled tasks that run in the always-on server."""
from __future__ import annotations
from .models import Schedule, ScheduledTask, TaskRun
from .scheduler import Scheduler
from .store import TaskStore, compute_next_run
from .tools import scheduling_tools
__all__ = [
"Schedule",
"ScheduledTask",
"TaskRun",
"Scheduler",
"TaskStore",
"compute_next_run",
"scheduling_tools",
]
+239
View File
@@ -0,0 +1,239 @@
"""Automation data model — a scheduled task is its own persistent entity (see
docs/AUTOMATION-SCHEDULING.md). Each fire is a fresh Run of the task's instructions, recorded
in the task's own thread + working folder.
"""
from __future__ import annotations
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Optional
_DOW = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
def _now() -> float:
return time.time()
# -- standing scoped approvals (UX-DECISIONS §25) --------------------------------
# An `always_allowed_tools` entry is either a bare tool name (legacy, allows the tool
# against any argument) or "tool target" — one space, tool names never contain spaces —
# binding the allowance to one exact target (channel address, recipient, …). Rules live
# on the task record so revocation is per-automation and deletion takes them along.
def rule_entry(tool: str, target: Optional[str] = None) -> str:
return f"{tool} {target}" if target else tool
def rule_parts(entry: str) -> tuple[str, Optional[str]]:
tool, _, target = entry.strip().partition(" ")
return tool, (target.strip() or None)
def grant_entries(permissions: Any) -> list[str]:
"""Validate a proposed `permissions` list (from the create-tool schema or the GUI
create payload) down to the entries actually grantable. Only `access: "write"` items
become grants; the tool must declare a target argument (which excludes exec/destructive
tools by construction) and the target must be non-empty. Reads are disclosure-only —
rendered on the consent card, never stored. Anything else is dropped, fail-closed.
"""
from ..connectors.tool_defs import target_arg_for
entries: list[str] = []
for item in permissions or []:
if not isinstance(item, dict):
continue
if str(item.get("access", "")).lower() != "write":
continue
tool = str(item.get("tool", "")).strip()
target = str(item.get("target", "")).strip()
if not tool or not target or target_arg_for(tool) is None:
continue
entry = rule_entry(tool, target)
if entry not in entries:
entries.append(entry)
return entries
def _human_time(hour: int, minute: int) -> str:
ampm = "AM" if hour < 12 else "PM"
h12 = hour % 12 or 12
return f"{h12}:{minute:02d} {ampm}"
@dataclass
class Schedule:
kind: str # "cron" | "once"
cron: Optional[str] = None
fire_at: Optional[str] = None # ISO datetime for one-time
timezone: str = (
"local" # 'local' = the machine's clock (a local-first tool default)
)
def human(self) -> str:
"""Best-effort human label ('Every day at ~7:10 PM'); falls back to the raw cron."""
if self.kind == "once":
return f"Once at {self.fire_at}"
parts = (self.cron or "").split()
if len(parts) != 5:
return self.cron or "?"
minute, hour, dom, month, dow = parts
try:
t = _human_time(int(hour), int(minute))
except ValueError:
return self.cron # non-trivial cron (ranges/steps) — show as-is
if dom == "*" and dow == "*":
return f"Every day at ~{t}"
if dom == "*" and dow.isdigit():
return f"Every {_DOW[int(dow) % 7]} at ~{t}"
if dom.isdigit() and dow == "*":
return f"Monthly on day {dom} at ~{t}"
return self.cron
def to_dict(self) -> dict:
return {
"kind": self.kind,
"cron": self.cron,
"fire_at": self.fire_at,
"timezone": self.timezone,
}
@classmethod
def from_dict(cls, d: dict) -> "Schedule":
return cls(
kind=d.get("kind", "cron"),
cron=d.get("cron"),
fire_at=d.get("fire_at"),
timezone=d.get("timezone", "local"),
)
@dataclass
class ScheduledTask:
title: str
instructions: str
schedule: Schedule
workspace: str
origin_surface: str = "cowork" # where it was launched from (a reference)
origin_session_id: str = ""
agent: str = "cowork"
id: str = field(default_factory=lambda: "task-" + uuid.uuid4().hex[:10])
task_session_id: str = "" # the task's OWN thread (set to f"__task__{id}")
model: Optional[str] = None
notify_on_completion: bool = True
notify_target: Optional[str] = None # extra messaging target ("telegram:123")
always_allowed_tools: list[str] = field(default_factory=list)
always_allowed_commands: list[str] = field(default_factory=list)
enabled: bool = True
created_at: float = field(default_factory=_now)
updated_at: float = field(default_factory=_now)
next_run: Optional[float] = None # epoch seconds; computed by the store
last_run: Optional[float] = None
last_status: Optional[str] = None
run_count: int = 0
max_runs: Optional[int] = None
# Sidebar unread tracking (UX-023): runs started after this mark count as
# "unseen"; opening the automation's detail advances it. 0.0 = never opened.
seen_runs_at: float = 0.0
def __post_init__(self) -> None:
if not self.task_session_id:
self.task_session_id = f"__task__{self.id}"
def to_dict(self) -> dict:
d = self.__dict__.copy()
d["schedule"] = self.schedule.to_dict()
return d
@classmethod
def from_dict(cls, d: dict) -> "ScheduledTask":
d = dict(d)
d["schedule"] = Schedule.from_dict(d.get("schedule") or {})
return cls(**d)
# -- standing rules (§25) --------------------------------------------------
def standing_rules(self) -> dict[str, set[str]]:
"""Target-bound entries as {tool: {targets}} — the shape the permission engine
matches against the declared target argument."""
out: dict[str, set[str]] = {}
for entry in self.always_allowed_tools:
tool, target = rule_parts(entry)
if tool and target:
out.setdefault(tool, set()).add(target)
return out
def name_allowed_tools(self) -> set[str]:
"""Legacy name-only entries (no target binding) — back-compatible behavior."""
return {
tool
for tool, target in map(rule_parts, self.always_allowed_tools)
if tool and target is None
}
def add_rule(self, tool: str, target: str) -> bool:
entry = rule_entry(tool, target)
if not tool or not target or entry in self.always_allowed_tools:
return False
self.always_allowed_tools.append(entry)
return True
def revoke_rule(self, entry: str) -> bool:
if entry in self.always_allowed_tools:
self.always_allowed_tools.remove(entry)
return True
return False
def public(self) -> dict[str, Any]:
"""Status shape for the API/UI (no instructions truncation; never any secret)."""
return {
"id": self.id,
"title": self.title,
"instructions": self.instructions,
"schedule": self.schedule.human(),
"schedule_raw": self.schedule.to_dict(),
"workspace": self.workspace,
"agent": self.agent,
"enabled": self.enabled,
"next_run": self.next_run,
"last_run": self.last_run,
"last_status": self.last_status,
"run_count": self.run_count,
"notify_on_completion": self.notify_on_completion,
# UX-023: lets the detail freeze the pre-open mark for its "new" pills.
"seen_runs_at": self.seen_runs_at,
# Structured for the task page's revoke list; `entry` is the revoke handle.
"always_allowed": [
{"entry": e, "tool": t, "target": tg}
for e, (t, tg) in (
(e, rule_parts(e)) for e in sorted(set(self.always_allowed_tools))
)
],
}
@dataclass
class TaskRun:
task_id: str
run_id: str = field(default_factory=lambda: "run-" + uuid.uuid4().hex[:10])
started_at: float = field(default_factory=_now)
finished_at: Optional[float] = None
status: str = "running" # running | ok | error | skipped
result_text: Optional[str] = None
artifacts: list[str] = field(default_factory=list)
error: Optional[str] = None
trigger: str = "schedule" # schedule | manual | catchup
session_id: str = "" # the run's own conversation thread — persisted + continuable
def __post_init__(self) -> None:
if not self.session_id:
self.session_id = f"__run__{self.run_id}"
def to_dict(self) -> dict:
return self.__dict__.copy()
@classmethod
def from_dict(cls, d: dict) -> "TaskRun":
return cls(**d)
+113
View File
@@ -0,0 +1,113 @@
"""The scheduler loop — runs in the always-on server.
Policy (agreed): **run-once-catch-up** for runs missed while down (due tasks fire once on
startup, then resume), and **skip-on-overlap** (don't stack a run if the previous is still
going). The actual execution is injected as `runner(task, trigger) -> TaskRun` so this stays
independent of the engine/manager.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Awaitable, Callable, Optional
from .models import ScheduledTask, TaskRun
from .store import TaskStore
logger = logging.getLogger("coworker.automation")
Runner = Callable[[ScheduledTask, str], Awaitable[TaskRun]]
class Scheduler:
def __init__(
self,
store: TaskStore,
runner: Runner,
*,
tick_seconds: float = 30.0,
extra_tick: Optional[Callable[[], Awaitable[None]]] = None,
) -> None:
self.store = store
self.runner = runner
self.tick_seconds = tick_seconds
# An extra per-tick coroutine (self-wake resumption: resume sessions whose wakes are due).
self.extra_tick = extra_tick
self._task: Optional[asyncio.Task] = None
self._running_ids: set[str] = set() # overlap guard
self._spawned: set[asyncio.Task] = set() # keep spawned runs referenced
def start(self) -> None:
if self._task is None:
self._task = asyncio.create_task(self._loop())
async def stop(self) -> None:
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
# In-flight runs died with the loop before they were spawned; keep that shutdown
# contract now that they're independent tasks (a suspended run must not outlive us).
for spawned in list(self._spawned):
spawned.cancel()
try:
await spawned
except asyncio.CancelledError:
pass
self._spawned.clear()
async def _loop(self) -> None:
# First pass = run-once-catch-up for anything missed while the server was down.
try:
await self._tick(trigger="catchup")
except Exception:
logger.exception("scheduler catch-up failed")
while True:
await asyncio.sleep(self.tick_seconds)
try:
await self._tick(trigger="schedule")
except Exception:
logger.exception("scheduler tick failed")
async def _tick(self, *, trigger: str) -> None:
for task in self.store.due():
# Spawn, don't await: a run can suspend on a parked approval (standing
# scoped approvals, §25) and one blocked automation must never stall the
# scheduler loop, other due tasks, or self-wake resumption. Overlap is
# still guarded inside run_task via _running_ids.
spawned = asyncio.create_task(self.run_task(task, trigger=trigger))
self._spawned.add(spawned)
spawned.add_done_callback(self._spawned.discard)
if self.extra_tick is not None:
try:
await self.extra_tick()
except Exception:
logger.exception("scheduler extra_tick (wake resume) failed")
async def run_task(self, task: ScheduledTask, *, trigger: str) -> Optional[TaskRun]:
if task.id in self._running_ids: # skip-on-overlap
logger.info("skipping %s — previous run still going", task.id)
return None
self._running_ids.add(task.id)
try:
run = await self.runner(task, trigger)
except Exception as exc:
logger.exception("task %s run failed", task.id)
run = TaskRun(
task_id=task.id, status="error", error=str(exc), trigger=trigger
)
self.store.add_run(run)
finally:
self._running_ids.discard(task.id)
# advance the task (run_count/last_run) → save recomputes next_run.
fresh = self.store.get(task.id)
if fresh is not None:
fresh.run_count += 1
fresh.last_run = run.started_at if run else None
fresh.last_status = run.status if run else "error"
self.store.save(fresh)
return run
+176
View File
@@ -0,0 +1,176 @@
"""SQLite-backed store for scheduled tasks + run history.
Tasks/runs are stored as JSON blobs with a few indexed columns (next_run, enabled) so the
scheduler can cheaply find what's due. `next_run` is computed with croniter, honoring the
task's timezone. Thread-safe (check_same_thread=False + a lock) since the scheduler and the
request handlers touch it from different threads.
"""
from __future__ import annotations
import json
import sqlite3
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from zoneinfo import ZoneInfo
from .models import ScheduledTask, TaskRun
def compute_next_run(
task: ScheduledTask, *, after: Optional[float] = None
) -> Optional[float]:
"""Next fire time (epoch seconds), or None if the task is exhausted/one-shot-past."""
sched = task.schedule
now = after if after is not None else _epoch_now()
if sched.kind == "once":
if not sched.fire_at:
return None
try:
dt = datetime.fromisoformat(sched.fire_at)
except ValueError:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=_tz(sched.timezone))
ts = dt.timestamp()
return ts if (task.run_count == 0 and ts > now) else None
# cron
from croniter import croniter
if not sched.cron or not croniter.is_valid(sched.cron):
return None
if task.max_runs is not None and task.run_count >= task.max_runs:
return None
base = datetime.fromtimestamp(now, tz=_tz(sched.timezone))
return croniter(sched.cron, base).get_next(datetime).timestamp()
def _tz(name: str):
"""Resolve a schedule timezone. 'local'/empty → the machine's local zone (right for a
local-first tool: when you say '8:05 PM' you mean *your* clock, not UTC)."""
if not name or name.lower() == "local":
return datetime.now().astimezone().tzinfo
try:
return ZoneInfo(name)
except Exception:
return datetime.now().astimezone().tzinfo
def _epoch_now() -> float:
return datetime.now(timezone.utc).timestamp()
class TaskStore:
def __init__(self, path: str | Path) -> None:
self.path = str(path)
self._lock = threading.RLock()
self._conn = sqlite3.connect(self.path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._init()
def _init(self) -> None:
with self._lock:
self._conn.executescript("""
CREATE TABLE IF NOT EXISTS scheduled_tasks (
id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
next_run REAL,
data TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS task_runs (
run_id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
started_at REAL NOT NULL,
data TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_runs_task ON task_runs(task_id, started_at DESC);
""")
self._conn.commit()
# -- tasks ------------------------------------------------------------------
def save(self, task: ScheduledTask) -> ScheduledTask:
task.updated_at = _epoch_now()
task.next_run = compute_next_run(task) if task.enabled else None
with self._lock:
self._conn.execute(
"INSERT OR REPLACE INTO scheduled_tasks (id, enabled, next_run, data) VALUES (?, ?, ?, ?)",
(
task.id,
1 if task.enabled else 0,
task.next_run,
json.dumps(task.to_dict()),
),
)
self._conn.commit()
return task
def get(self, task_id: str) -> Optional[ScheduledTask]:
with self._lock:
row = self._conn.execute(
"SELECT data FROM scheduled_tasks WHERE id=?", (task_id,)
).fetchone()
return ScheduledTask.from_dict(json.loads(row["data"])) if row else None
def list(self) -> list[ScheduledTask]:
with self._lock:
rows = self._conn.execute(
"SELECT data FROM scheduled_tasks ORDER BY next_run IS NULL, next_run"
).fetchall()
return [ScheduledTask.from_dict(json.loads(r["data"])) for r in rows]
def delete(self, task_id: str) -> bool:
with self._lock:
cur = self._conn.execute(
"DELETE FROM scheduled_tasks WHERE id=?", (task_id,)
)
self._conn.execute("DELETE FROM task_runs WHERE task_id=?", (task_id,))
self._conn.commit()
return cur.rowcount > 0
def due(self, *, now: Optional[float] = None) -> list[ScheduledTask]:
now = now if now is not None else _epoch_now()
with self._lock:
rows = self._conn.execute(
"SELECT data FROM scheduled_tasks WHERE enabled=1 AND next_run IS NOT NULL AND next_run<=? ORDER BY next_run",
(now,),
).fetchall()
return [ScheduledTask.from_dict(json.loads(r["data"])) for r in rows]
# -- runs -------------------------------------------------------------------
def add_run(self, run: TaskRun) -> TaskRun:
with self._lock:
self._conn.execute(
"INSERT OR REPLACE INTO task_runs (run_id, task_id, started_at, data) VALUES (?, ?, ?, ?)",
(run.run_id, run.task_id, run.started_at, json.dumps(run.to_dict())),
)
self._conn.commit()
return run
def find_run(self, run_id: str) -> Optional[TaskRun]:
with self._lock:
row = self._conn.execute(
"SELECT data FROM task_runs WHERE run_id=?", (run_id,)
).fetchone()
return TaskRun.from_dict(json.loads(row["data"])) if row else None
def task_for_run_session(self, session_id: str) -> Optional[ScheduledTask]:
"""The owning task of a run session ('__run__<run_id>'), or None. How standing
scoped approvals resolve which automation a live approval belongs to (§25)."""
if not session_id.startswith("__run__"):
return None
run = self.find_run(session_id[len("__run__") :])
return self.get(run.task_id) if run else None
def runs(self, task_id: str, *, limit: int = 50) -> list[TaskRun]:
with self._lock:
rows = self._conn.execute(
"SELECT data FROM task_runs WHERE task_id=? ORDER BY started_at DESC LIMIT ?",
(task_id, limit),
).fetchall()
return [TaskRun.from_dict(json.loads(r["data"])) for r in rows]
def close(self) -> None:
with self._lock:
self._conn.close()
+233
View File
@@ -0,0 +1,233 @@
"""Agent-facing scheduling tools (Cowork + MyHelper).
`create_scheduled_task` is gated (`requires_approval`) so it surfaces a confirm card before a
standing automation is created (approve-at-creation). The agent converts natural language
("7:10pm everyday") into a cron string itself. Tools are origin-bound: a created task records
the launching session and runs in its workspace, so the origin conversation can read the
results (the artifacts are real files in that folder).
"""
from __future__ import annotations
from typing import Any, Callable, Optional
import aisuite as ai
from .models import Schedule, ScheduledTask, grant_entries
from .store import TaskStore
_CREATE_SCHEMA = {
"type": "function",
"function": {
"name": "create_scheduled_task",
"description": (
"Create a scheduled automation that re-runs `instructions` on a schedule. Convert "
"the user's natural-language timing into a cron expression yourself (e.g. "
"'every day at 7:10pm''10 19 * * *'), or pass a one-time `fire_at` ISO datetime. "
"The user confirms before it is created."
),
"parameters": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Short label, e.g. 'Daily news briefing'.",
},
"instructions": {
"type": "string",
"description": (
"What to do on each run, written as a direct command to execute "
"immediately (e.g. 'Prepare a market analysis report covering …'). Do "
"NOT restate the schedule or timing here — timing belongs in cron/"
"fire_at; this text is handed verbatim to the agent every run."
),
},
"cron": {
"type": "string",
"description": "5-field cron, e.g. '10 19 * * *'. Omit for one-time.",
},
"fire_at": {
"type": "string",
"description": "ISO datetime for a one-time run. Omit for recurring.",
},
"timezone": {
"type": "string",
"description": "IANA tz, e.g. 'America/New_York'. Defaults to the machine's local time — pass it only to override.",
},
"permissions": {
"type": "array",
"description": (
"What this automation will touch, surfaced on the creation consent "
"card. List every external read and write the instructions imply. "
"Reads (access:'read') are disclosure only. Writes (access:'write') "
"become standing grants IF the user approves: the automation may then "
"call that exact tool against that exact target without asking each "
"run. Targets must be exact (a channel address like 'slack:T…/C…', a "
"recipient) — no wildcards. Omit writes whose target you don't know "
"yet; the run will ask instead."
),
"items": {
"type": "object",
"properties": {
"tool": {
"type": "string",
"description": "Exact tool name, e.g. 'send_message'.",
},
"target": {
"type": "string",
"description": "The exact target argument value the rule binds to.",
},
"access": {
"type": "string",
"enum": ["read", "write"],
"description": "'write' proposes a standing grant; 'read' is disclosure.",
},
},
"required": ["tool", "target", "access"],
},
},
},
"required": ["title", "instructions"],
},
},
}
_UPDATE_SCHEMA = {
"type": "function",
"function": {
"name": "update_scheduled_task",
"description": "Enable/disable or edit a scheduled task (its instructions, cron, or title).",
"parameters": {
"type": "object",
"properties": {
"id": {"type": "string"},
"enabled": {"type": "boolean"},
"instructions": {"type": "string"},
"cron": {"type": "string"},
"title": {"type": "string"},
},
"required": ["id"],
},
},
}
_ID_SCHEMA = {
"type": "function",
"function": {
"name": "delete_scheduled_task",
"description": "Delete a scheduled task and its run history.",
"parameters": {
"type": "object",
"properties": {"id": {"type": "string"}},
"required": ["id"],
},
},
}
_LIST_SCHEMA = {
"type": "function",
"function": {
"name": "list_scheduled_tasks",
"description": "List the user's scheduled tasks (title, schedule, next run, status).",
"parameters": {"type": "object", "properties": {}},
},
}
def _gated(func: Callable, schema: dict, *, approval: bool) -> Callable:
func.__name__ = schema["function"]["name"]
func.__doc__ = schema["function"]["description"]
func.__aisuite_tool_metadata__ = ai.ToolMetadata(
name=schema["function"]["name"],
category="automation",
risk_level="medium" if approval else "low",
capabilities=["scheduling"],
requires_approval=approval,
)
func.__coworker_schema__ = schema
return func
def scheduling_tools(
store: TaskStore,
*,
origin: dict[str, Any],
default_workspace: str,
) -> list[Callable[..., Any]]:
def create_scheduled_task(
title, instructions, cron=None, fire_at=None, timezone="local", permissions=None
):
from croniter import croniter
if not cron and not fire_at:
return {
"error": "provide a cron (recurring) or a fire_at ISO datetime (one-time)"
}
if cron and not croniter.is_valid(cron):
return {"error": f"invalid cron expression: {cron}"}
schedule = Schedule(
kind="once" if (fire_at and not cron) else "cron",
cron=cron,
fire_at=fire_at,
timezone=timezone or "local",
)
workspace = origin.get("workspace") or default_workspace
# The agent PROPOSES permissions; the human granted them by approving this gated
# call (the consent card rendered the proposal). Only validated write grants stick:
# tool must declare a target argument (never exec/destructive), target non-empty.
grants = grant_entries(permissions)
task = ScheduledTask(
title=title,
instructions=instructions,
schedule=schedule,
workspace=workspace,
origin_surface=origin.get("surface", "cowork"),
origin_session_id=origin.get("session_id", ""),
agent=origin.get("agent", "cowork"),
always_allowed_tools=grants,
)
store.save(task)
return {
"ok": True,
"id": task.id,
"title": title,
"schedule": schedule.human(),
"next_run": task.next_run,
"workspace": workspace,
"always_allowed": grants,
}
def list_scheduled_tasks():
return {"tasks": [t.public() for t in store.list()]}
def update_scheduled_task(
id, enabled=None, instructions=None, cron=None, title=None
):
from croniter import croniter
task = store.get(id)
if task is None:
return {"error": f"no such task: {id}"}
if cron is not None:
if not croniter.is_valid(cron):
return {"error": f"invalid cron expression: {cron}"}
task.schedule.cron = cron
task.schedule.kind = "cron"
if enabled is not None:
task.enabled = bool(enabled)
if instructions is not None:
task.instructions = instructions
if title is not None:
task.title = title
store.save(task)
return {"ok": True, "task": task.public()}
def delete_scheduled_task(id):
return {"ok": store.delete(id), "id": id}
return [
_gated(create_scheduled_task, _CREATE_SCHEMA, approval=True),
_gated(list_scheduled_tasks, _LIST_SCHEMA, approval=False),
_gated(update_scheduled_task, _UPDATE_SCHEMA, approval=True),
_gated(delete_scheduled_task, _ID_SCHEMA, approval=True),
]