handler-ai 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- handler_ai-0.1.0.dist-info/METADATA +60 -0
- handler_ai-0.1.0.dist-info/RECORD +234 -0
- handler_ai-0.1.0.dist-info/WHEEL +4 -0
- handler_ai-0.1.0.dist-info/entry_points.txt +6 -0
- handler_cli/__init__.py +1 -0
- handler_cli/account_commands.py +314 -0
- handler_cli/addon_commands.py +128 -0
- handler_cli/agent_commands.py +139 -0
- handler_cli/app_commands.py +175 -0
- handler_cli/app_window.py +85 -0
- handler_cli/automation_commands.py +309 -0
- handler_cli/bridge.py +42 -0
- handler_cli/cli.py +326 -0
- handler_cli/client.py +81 -0
- handler_cli/daemon_commands.py +108 -0
- handler_cli/image_commands.py +102 -0
- handler_cli/inbox_commands.py +33 -0
- handler_cli/output.py +12 -0
- handler_cli/project_commands.py +63 -0
- handler_cli/provider_commands.py +113 -0
- handler_cli/secret_commands.py +64 -0
- handler_cli/session_bridge.py +93 -0
- handler_cli/session_commands.py +485 -0
- handler_cli/setup.py +120 -0
- handler_cli/terminal.py +280 -0
- handler_cli/terminal_client.py +37 -0
- handler_contracts/__init__.py +642 -0
- handler_contracts/account.py +36 -0
- handler_contracts/acp_endpoint.py +69 -0
- handler_contracts/addon.py +147 -0
- handler_contracts/agent_spec.py +75 -0
- handler_contracts/automation.py +285 -0
- handler_contracts/common.py +229 -0
- handler_contracts/control.py +1208 -0
- handler_contracts/harness.py +300 -0
- handler_contracts/miniapp.py +141 -0
- handler_contracts/provider.py +82 -0
- handler_contracts/remote.py +167 -0
- handler_contracts/sandbox.py +199 -0
- handler_contracts/schemas.py +313 -0
- handler_contracts/terminal.py +32 -0
- handler_control/__init__.py +7 -0
- handler_control/access.py +62 -0
- handler_control/accounts.py +279 -0
- handler_control/addon_install.py +268 -0
- handler_control/addons.py +118 -0
- handler_control/agent_state.py +46 -0
- handler_control/agents.py +257 -0
- handler_control/api/__init__.py +137 -0
- handler_control/api/accounts.py +231 -0
- handler_control/api/acp.py +116 -0
- handler_control/api/addons.py +95 -0
- handler_control/api/agents.py +150 -0
- handler_control/api/apps.py +104 -0
- handler_control/api/automations.py +128 -0
- handler_control/api/bindings.py +105 -0
- handler_control/api/builtin_agents.py +22 -0
- handler_control/api/caches.py +79 -0
- handler_control/api/catalog.py +197 -0
- handler_control/api/common.py +205 -0
- handler_control/api/contexts.py +88 -0
- handler_control/api/folders.py +51 -0
- handler_control/api/images.py +164 -0
- handler_control/api/inbox.py +21 -0
- handler_control/api/projects.py +170 -0
- handler_control/api/providers.py +107 -0
- handler_control/api/remote.py +45 -0
- handler_control/api/secrets.py +177 -0
- handler_control/api/sessions.py +192 -0
- handler_control/api/skills.py +270 -0
- handler_control/app_api.py +84 -0
- handler_control/app_http.py +214 -0
- handler_control/app_remote.py +206 -0
- handler_control/apps.py +867 -0
- handler_control/auth.py +48 -0
- handler_control/automation_config.py +255 -0
- handler_control/automations.py +1134 -0
- handler_control/bindings.py +149 -0
- handler_control/builds.py +341 -0
- handler_control/builtin_agents.py +82 -0
- handler_control/caches.py +113 -0
- handler_control/checks.py +287 -0
- handler_control/compose.py +317 -0
- handler_control/config_files.py +41 -0
- handler_control/contexts.py +75 -0
- handler_control/daemon.py +64 -0
- handler_control/data/automation/bin/handler-llm +85 -0
- handler_control/data/automations/env-issues/automation.toml +17 -0
- handler_control/data/automations/env-issues/extract.py +73 -0
- handler_control/data/automations/env-issues/findings.schema.json +28 -0
- handler_control/data/automations/env-issues/prompt.md +19 -0
- handler_control/data/builtin-agents/skill-creator/agent.toml +10 -0
- handler_control/data/builtin-agents/skill-creator/instructions.md +5 -0
- handler_control/data/builtin-agents/skill-creator/skills/handler-skills/SKILL.md +44 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/LICENSE.txt +202 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/SKILL.md +485 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/analyzer.md +274 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/comparator.md +202 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/agents/grader.md +223 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/assets/eval_review.html +146 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/eval-viewer/generate_review.py +471 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/eval-viewer/viewer.html +1325 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/references/schemas.md +430 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/__init__.py +0 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/generate_report.py +326 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/improve_description.py +247 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/package_skill.py +136 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/quick_validate.py +103 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/run_eval.py +310 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/run_loop.py +328 -0
- handler_control/data/builtin-agents/skill-creator/skills/skill-creator/scripts/utils.py +47 -0
- handler_control/data/builtin-agents/skill-review/agent.toml +13 -0
- handler_control/data/builtin-agents/skill-review/instructions.md +30 -0
- handler_control/data/builtin-agents/skill-review/interface.json +83 -0
- handler_control/data/interfaces/llm/v1/interface.toml +17 -0
- handler_control/data/interfaces/llm/v1/tools.json +138 -0
- handler_control/data/templates/README.md +10 -0
- handler_control/data/templates/providers/anthropic-api.toml +18 -0
- handler_control/data/templates/providers/llamacpp.toml +210 -0
- handler_control/data/templates/providers/mistral-api.toml +14 -0
- handler_control/data/templates/providers/ollama.toml +10 -0
- handler_control/data/templates/providers/openai-api.toml +13 -0
- handler_control/data/templates/providers/openrouter.toml +10 -0
- handler_control/desktop.py +68 -0
- handler_control/display.py +62 -0
- handler_control/files.py +23 -0
- handler_control/folders.py +68 -0
- handler_control/git_read.py +39 -0
- handler_control/image_check.py +322 -0
- handler_control/image_updates.py +174 -0
- handler_control/inbox.py +70 -0
- handler_control/interfaces.py +151 -0
- handler_control/knowledge.py +237 -0
- handler_control/launcher.py +356 -0
- handler_control/llm.py +194 -0
- handler_control/migrations/__init__.py +62 -0
- handler_control/policy.py +305 -0
- handler_control/projects.py +266 -0
- handler_control/providers.py +180 -0
- handler_control/relay.py +133 -0
- handler_control/remote.py +888 -0
- handler_control/remote_crypto.py +97 -0
- handler_control/runtime.py +140 -0
- handler_control/sandbox_process.py +224 -0
- handler_control/schedules.py +58 -0
- handler_control/secrets.py +242 -0
- handler_control/service_router.py +180 -0
- handler_control/services.py +92 -0
- handler_control/session_files.py +429 -0
- handler_control/sessions.py +1116 -0
- handler_control/settings.py +129 -0
- handler_control/shortcuts.py +80 -0
- handler_control/skill_scan.py +88 -0
- handler_control/skill_sources.py +61 -0
- handler_control/skills.py +378 -0
- handler_control/terminal.py +79 -0
- handler_control/terminals.py +108 -0
- handler_control/transcript.py +174 -0
- handler_control/web-ui/assets/index-1w5RPsfA.js +190 -0
- handler_control/web-ui/assets/index-dWnzCLzQ.css +1 -0
- handler_control/web-ui/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- handler_control/web-ui/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- handler_control/web-ui/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- handler_control/web-ui/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- handler_control/web-ui/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- handler_control/web-ui/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- handler_control/web-ui/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- handler_control/web-ui/favicon.svg +1 -0
- handler_control/web-ui/index.html +15 -0
- handler_control/webpush.py +90 -0
- handler_control/webui.py +105 -0
- handler_harness/__init__.py +13 -0
- handler_harness/addons.py +129 -0
- handler_harness/catalog.py +116 -0
- handler_harness/cli.py +109 -0
- handler_harness/data/addons/README.md +44 -0
- handler_harness/data/addons/miniapp/Containerfile +20 -0
- handler_harness/data/addons/miniapp/PROVISIONING.md +16 -0
- handler_harness/data/addons/miniapp/addon.toml +16 -0
- handler_harness/data/addons/miniapp/skills/miniapp/SKILL.md +140 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/.gitignore +2 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/app.toml +15 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/db.ts +30 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/deno.json +7 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/handler.ts +26 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/main.ts +18 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/main_test.ts +32 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/migrations/001_notes.sql +6 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/server/routes.ts +32 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/deno.json +12 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/index.html +12 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/App.svelte +44 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/api.ts +16 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/app.css +24 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/src/main.ts +5 -0
- handler_harness/data/addons/miniapp/skills/miniapp/template/ui/vite.config.ts +17 -0
- handler_harness/data/catalog/README.md +12 -0
- handler_harness/data/catalog/playwright/Containerfile +28 -0
- handler_harness/data/catalog/playwright/PROVISIONING.md +53 -0
- handler_harness/data/catalog/playwright/addon.toml +25 -0
- handler_harness/data/catalog/playwright/handler/playwright-mcp-check.mjs +23 -0
- handler_harness/data/catalog/playwright/handler/playwright-mcp.mjs +61 -0
- handler_harness/data/catalog/playwright/skills/browser/SKILL.md +41 -0
- handler_harness/data/catalog/shopify/Containerfile +22 -0
- handler_harness/data/catalog/shopify/PROVISIONING.md +49 -0
- handler_harness/data/catalog/shopify/addon.toml +39 -0
- handler_harness/data/catalog/shopify/skills/shopify/SKILL.md +41 -0
- handler_harness/data/handler-sandbox-env +30 -0
- handler_harness/data/harnesses/README.md +47 -0
- handler_harness/data/harnesses/claude-code/Containerfile +27 -0
- handler_harness/data/harnesses/claude-code/harness.toml +95 -0
- handler_harness/data/harnesses/codex/Containerfile +26 -0
- handler_harness/data/harnesses/codex/harness.toml +103 -0
- handler_harness/data/harnesses/pi/Containerfile +27 -0
- handler_harness/data/harnesses/pi/harness.toml +65 -0
- handler_harness/data/harnesses/vibe/Containerfile +19 -0
- handler_harness/data/harnesses/vibe/harness.toml +96 -0
- handler_harness/data/toolbox/Containerfile +56 -0
- handler_harness/data/toolbox/README.md +24 -0
- handler_harness/data/toolbox/toolbox.toml +41 -0
- handler_harness/images.py +62 -0
- handler_harness/providers.py +300 -0
- handler_harness/render.py +472 -0
- handler_sandbox_podman/__init__.py +1 -0
- handler_sandbox_podman/caches.py +97 -0
- handler_sandbox_podman/check.py +121 -0
- handler_sandbox_podman/cli.py +239 -0
- handler_sandbox_podman/egress.py +118 -0
- handler_sandbox_podman/images.py +199 -0
- handler_sandbox_podman/launch.py +474 -0
- handler_sandbox_podman/podman.py +108 -0
- handler_sandbox_podman/proxy/Containerfile +6 -0
- handler_sandbox_podman/proxy/addon.py +255 -0
|
@@ -0,0 +1,1134 @@
|
|
|
1
|
+
"""Automations (I): when a session stops or a schedule's slot comes, the enabled automations it
|
|
2
|
+
fires run, one at a time for the whole daemon. A run is the automation's script in a sandbox (on
|
|
3
|
+
the new part of the conversation, for a stop; once it succeeded, handlerd appends what it wrote to
|
|
4
|
+
the declared knowledge notes), a run of an agent with an interface (its result goes to the inbox),
|
|
5
|
+
or a call to a mini app's route.
|
|
6
|
+
|
|
7
|
+
pending_stop pure: the stop of a log that fires an automation past its cursor (I.2)
|
|
8
|
+
result_problems pure: why an interface's result cannot make an inbox message (I.3)
|
|
9
|
+
Automations the runner: stops and schedules checked, the queue, runs and their
|
|
10
|
+
records, on and off (I.7), tries (I.8), what the API shows (I.9)
|
|
11
|
+
|
|
12
|
+
No other log than the sessions' own: a stop is told by `SessionStore.stop_listeners` (only the
|
|
13
|
+
session's id: the check reads its log, off the event loop), and at start every session is checked
|
|
14
|
+
once (the catch-up scan: stops while handlerd was not running, runs cut by its stop). Schedules are
|
|
15
|
+
checked every SCHEDULE_TICK seconds. Nothing pending is stored: a cursor per (automation, session),
|
|
16
|
+
moved when a run ends, is what was read; a schedule's last slot, moved when its run starts.
|
|
17
|
+
|
|
18
|
+
Storage, per automation (automation_config.state_dir):
|
|
19
|
+
state.json on or off, since, the cursors, the schedule's last slot
|
|
20
|
+
runs/<runId>/ run.json, definition/, input/, output/, run.log, services.ndjson
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import contextlib
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import os
|
|
28
|
+
import secrets as pysecrets
|
|
29
|
+
import shutil
|
|
30
|
+
import threading
|
|
31
|
+
from collections.abc import Callable, Sequence
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from datetime import UTC, datetime, timedelta
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from handler_contracts import (
|
|
38
|
+
DEFAULT_TIMEOUT_SEC,
|
|
39
|
+
MAX_APPEND,
|
|
40
|
+
Automation,
|
|
41
|
+
AutomationAgentRun,
|
|
42
|
+
AutomationAppended,
|
|
43
|
+
AutomationCheck,
|
|
44
|
+
AutomationLlmUsage,
|
|
45
|
+
AutomationRun,
|
|
46
|
+
AutomationView,
|
|
47
|
+
InboxStatus,
|
|
48
|
+
SessionInput,
|
|
49
|
+
SessionOutput,
|
|
50
|
+
)
|
|
51
|
+
from handler_harness import catalog, images
|
|
52
|
+
|
|
53
|
+
from . import automation_config as config
|
|
54
|
+
from . import (
|
|
55
|
+
checks,
|
|
56
|
+
compose,
|
|
57
|
+
interfaces,
|
|
58
|
+
knowledge,
|
|
59
|
+
runtime,
|
|
60
|
+
sandbox_process,
|
|
61
|
+
schedules,
|
|
62
|
+
secrets,
|
|
63
|
+
service_router,
|
|
64
|
+
transcript,
|
|
65
|
+
)
|
|
66
|
+
from .agents import AgentRecord, AgentRegistry
|
|
67
|
+
from .apps import AppError, AppStore
|
|
68
|
+
from .inbox import Inbox
|
|
69
|
+
from .projects import ProjectRegistry
|
|
70
|
+
from .sessions import SessionRecord, SessionStore
|
|
71
|
+
from .settings import Settings
|
|
72
|
+
|
|
73
|
+
log = logging.getLogger("handler.automations")
|
|
74
|
+
|
|
75
|
+
BIN = Path(__file__).resolve().parent / "data" / "automation" / "bin" # handler-llm
|
|
76
|
+
DAILY_CAP = 100 # runs per automation per day (I.3)
|
|
77
|
+
KEEP_RUNS = 50
|
|
78
|
+
KEEP_DAYS = 30
|
|
79
|
+
MAX_LOG = 1024 * 1024 # run.log
|
|
80
|
+
PREVIEW = 4096 # of what a try would append
|
|
81
|
+
NOT_FIRING = ("deleted", "daemon_restarted")
|
|
82
|
+
LAST = 2**62 # a seq past any log's end
|
|
83
|
+
LINE_LIMIT = 1024 * 1024 # one line of a run's output
|
|
84
|
+
SCHEDULE_TICK = 20.0 # schedules are checked this often (seconds)
|
|
85
|
+
DELETE_WAIT = 30.0 # a deleted one's run in progress is given this long to stop (seconds)
|
|
86
|
+
SESSION_POLL = 1.0 # an agent run's session is looked at this often (seconds)
|
|
87
|
+
STATUSES = ("ok", "attention", "failed")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class AutomationError(ValueError):
|
|
91
|
+
"""What was asked cannot be done (the message says why)."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AutomationNotFoundError(AutomationError):
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class AutomationConflictError(AutomationError):
|
|
99
|
+
"""The state does not allow it: problems, a run that ended."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class AutomationCappedError(AutomationError):
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def now() -> datetime:
|
|
107
|
+
return datetime.now(UTC)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def pending_stop(
|
|
111
|
+
events: Sequence[dict[str, Any]], cursor: int, since: datetime | None
|
|
112
|
+
) -> dict[str, Any] | None:
|
|
113
|
+
"""The last stop after `cursor` that fires (I.2): not `deleted` nor `daemon_restarted`, at or
|
|
114
|
+
after `since`, in a log with a turn after `cursor`. None: nothing to read."""
|
|
115
|
+
if not any(e.get("kind") == "turn" and e["seq"] > cursor for e in events):
|
|
116
|
+
return None
|
|
117
|
+
stops = [
|
|
118
|
+
e
|
|
119
|
+
for e in events
|
|
120
|
+
if e.get("kind") == "lifecycle"
|
|
121
|
+
and e.get("event") == "stopped"
|
|
122
|
+
and e["seq"] > cursor
|
|
123
|
+
and e.get("how") not in NOT_FIRING
|
|
124
|
+
and (since is None or datetime.fromisoformat(e["t"]) >= since)
|
|
125
|
+
]
|
|
126
|
+
return stops[-1] if stops else None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def result_problems(schema: dict[str, Any]) -> list[str]:
|
|
130
|
+
"""An agent run by an automation gives a result its inbox message is made of (I.3):
|
|
131
|
+
required `status` (some of ok, attention, failed) and `summary` (a string)."""
|
|
132
|
+
props = schema.get("properties") or {}
|
|
133
|
+
required = set(schema.get("required") or [])
|
|
134
|
+
found = []
|
|
135
|
+
status = props.get("status") or {}
|
|
136
|
+
if "status" not in required or not status.get("enum"):
|
|
137
|
+
found.append("its result has no required 'status' enum (ok, attention, failed)")
|
|
138
|
+
elif unknown := [v for v in status["enum"] if v not in STATUSES]:
|
|
139
|
+
found.append(f"its result's status has {unknown}: only ok, attention, failed")
|
|
140
|
+
if "summary" not in required or (props.get("summary") or {}).get("type") != "string":
|
|
141
|
+
found.append("its result has no required 'summary' string")
|
|
142
|
+
return found
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class Item:
|
|
147
|
+
"""A run waiting: for (automation, session), or (automation, its schedule), at most one
|
|
148
|
+
(coalescing); or a try."""
|
|
149
|
+
|
|
150
|
+
automation: str
|
|
151
|
+
session_id: str | None = None # a stop's
|
|
152
|
+
slot: datetime | None = None # a schedule's
|
|
153
|
+
missed: int = 0
|
|
154
|
+
trial: AutomationRun | None = None # a try's record, written when it was asked
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def key(self) -> tuple[str, str, str]:
|
|
158
|
+
return self.automation, self.session_id or "", self.trial.id if self.trial else ""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass
|
|
162
|
+
class Running:
|
|
163
|
+
record: AutomationRun
|
|
164
|
+
proc: Any = None # asyncio.subprocess.Process, once a script started
|
|
165
|
+
cancelled: bool = False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class Automations:
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
settings: Settings,
|
|
172
|
+
sessions: SessionStore,
|
|
173
|
+
registry: AgentRegistry,
|
|
174
|
+
projects: ProjectRegistry,
|
|
175
|
+
*,
|
|
176
|
+
apps: AppStore,
|
|
177
|
+
inbox: Inbox,
|
|
178
|
+
) -> None:
|
|
179
|
+
self.settings = settings
|
|
180
|
+
self.sessions = sessions
|
|
181
|
+
self.registry = registry
|
|
182
|
+
self.projects = projects
|
|
183
|
+
self.apps = apps
|
|
184
|
+
self.inbox = inbox
|
|
185
|
+
self.queue: dict[tuple[str, str, str], Item] = {} # in the order queued
|
|
186
|
+
self.stopped: set[str] = set() # sessions whose stop is to be checked
|
|
187
|
+
self.running: Running | None = None
|
|
188
|
+
self.started_today: dict[tuple[str, str], int] = {} # (automation, day) → runs
|
|
189
|
+
self.wake = asyncio.Event()
|
|
190
|
+
self.task: asyncio.Task[None] | None = None
|
|
191
|
+
self.lock = threading.Lock() # state.json: read, changed, written by one at a time
|
|
192
|
+
self.schedule_tick = SCHEDULE_TICK
|
|
193
|
+
self._load()
|
|
194
|
+
sessions.stop_listeners.append(self.on_stop)
|
|
195
|
+
|
|
196
|
+
# ── start, stop ─────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
def _load(self) -> None:
|
|
199
|
+
"""Runs the previous handlerd left queued or running are `interrupted` (a stop's part is
|
|
200
|
+
read again: the cursor did not move); today's runs count in the cap."""
|
|
201
|
+
today = now().date().isoformat()
|
|
202
|
+
for loaded in config.load_all(self.settings.automations_dir):
|
|
203
|
+
for record in self.runs(loaded.name):
|
|
204
|
+
if record.state in ("queued", "running"):
|
|
205
|
+
ended = record.model_copy(update={"state": "interrupted", "ended_at": now()})
|
|
206
|
+
self.write_record(ended)
|
|
207
|
+
if record.created_at.date().isoformat() == today:
|
|
208
|
+
key = (loaded.name, today)
|
|
209
|
+
self.started_today[key] = self.started_today.get(key, 0) + 1
|
|
210
|
+
|
|
211
|
+
def start(self) -> None:
|
|
212
|
+
self.stopped |= set(self.sessions.records) # the catch-up scan (I.2)
|
|
213
|
+
self.task = asyncio.create_task(self.work())
|
|
214
|
+
|
|
215
|
+
async def close(self) -> None:
|
|
216
|
+
if self.task is not None:
|
|
217
|
+
self.task.cancel()
|
|
218
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
219
|
+
await self.task
|
|
220
|
+
|
|
221
|
+
def on_stop(self, session_id: str) -> None:
|
|
222
|
+
"""A session's `lifecycle stopped` was written: checked later, off this path."""
|
|
223
|
+
self.stopped.add(session_id)
|
|
224
|
+
self.wake.set()
|
|
225
|
+
|
|
226
|
+
# ── the queue ───────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
async def work(self) -> None:
|
|
229
|
+
while True:
|
|
230
|
+
if self.stopped:
|
|
231
|
+
sessions, self.stopped = self.stopped, set()
|
|
232
|
+
found = await asyncio.to_thread(self.check, sessions)
|
|
233
|
+
for item in found:
|
|
234
|
+
self.queue.setdefault(item.key, item)
|
|
235
|
+
for item in await asyncio.to_thread(self.due):
|
|
236
|
+
self.queue[item.key] = item # a pending one gets the latest slot
|
|
237
|
+
item = self.next_item()
|
|
238
|
+
if item is None:
|
|
239
|
+
self.wake.clear()
|
|
240
|
+
with contextlib.suppress(TimeoutError): # a schedule, a capped one tomorrow
|
|
241
|
+
await asyncio.wait_for(self.wake.wait(), self.schedule_tick)
|
|
242
|
+
continue
|
|
243
|
+
try:
|
|
244
|
+
await self.run(item)
|
|
245
|
+
except asyncio.CancelledError:
|
|
246
|
+
raise
|
|
247
|
+
except Exception:
|
|
248
|
+
log.exception("automation %s", item.automation)
|
|
249
|
+
|
|
250
|
+
def next_item(self) -> Item | None:
|
|
251
|
+
for key, item in self.queue.items():
|
|
252
|
+
if not self.capped(item.automation):
|
|
253
|
+
del self.queue[key]
|
|
254
|
+
return item
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
def capped(self, name: str) -> bool:
|
|
258
|
+
return self.started_today.get((name, now().date().isoformat()), 0) >= DAILY_CAP
|
|
259
|
+
|
|
260
|
+
def count_start(self, name: str) -> None:
|
|
261
|
+
key = (name, now().date().isoformat())
|
|
262
|
+
self.started_today[key] = self.started_today.get(key, 0) + 1
|
|
263
|
+
|
|
264
|
+
def check(self, session_ids: set[str]) -> list[Item]:
|
|
265
|
+
"""The automations these sessions' stops fire (I.2)."""
|
|
266
|
+
found = []
|
|
267
|
+
logs: dict[str, list[dict[str, Any]]] = {} # each read once
|
|
268
|
+
for loaded in config.load_all(self.settings.automations_dir):
|
|
269
|
+
state = config.read_state(self.settings.state_dir, loaded.name)
|
|
270
|
+
definition = loaded.definition
|
|
271
|
+
if not state.enabled or definition is None or definition.when != "session.stopped":
|
|
272
|
+
continue
|
|
273
|
+
for sid in sorted(session_ids):
|
|
274
|
+
rec = self.sessions.records.get(sid)
|
|
275
|
+
if rec is None or rec.interface is not None or not self.matches(loaded, rec):
|
|
276
|
+
continue
|
|
277
|
+
path = self.log_path(sid)
|
|
278
|
+
with contextlib.suppress(OSError):
|
|
279
|
+
if state.since and path.stat().st_mtime < state.since.timestamp():
|
|
280
|
+
continue # written before it was turned on: nothing to read
|
|
281
|
+
if sid not in logs:
|
|
282
|
+
logs[sid] = transcript.read(path, 0, LAST)
|
|
283
|
+
if pending_stop(logs[sid], state.cursors.get(sid, 0), state.since):
|
|
284
|
+
found.append(Item(loaded.name, sid))
|
|
285
|
+
return found
|
|
286
|
+
|
|
287
|
+
def due(self) -> list[Item]:
|
|
288
|
+
"""The scheduled automations whose slot came (I.2): one run each, for the latest slot."""
|
|
289
|
+
t, found = now(), []
|
|
290
|
+
for loaded in config.load_all(self.settings.automations_dir):
|
|
291
|
+
schedule = loaded.definition.schedule if loaded.definition else None
|
|
292
|
+
if schedule is None:
|
|
293
|
+
continue
|
|
294
|
+
state = config.read_state(self.settings.state_dir, loaded.name)
|
|
295
|
+
last = state.last_slot or state.since
|
|
296
|
+
if not state.enabled or last is None:
|
|
297
|
+
continue
|
|
298
|
+
slot = schedules.latest_slot(schedule, last, t)
|
|
299
|
+
if slot is None:
|
|
300
|
+
continue
|
|
301
|
+
found.append(
|
|
302
|
+
Item(loaded.name, slot=slot, missed=schedules.missed(schedule, last, slot))
|
|
303
|
+
)
|
|
304
|
+
return found
|
|
305
|
+
|
|
306
|
+
def matches(self, loaded: config.Loaded, rec: SessionRecord) -> bool:
|
|
307
|
+
only = loaded.definition.only if loaded.definition else None
|
|
308
|
+
if only is None:
|
|
309
|
+
return True
|
|
310
|
+
agent, project = self.names(rec)
|
|
311
|
+
return (only.project is None or project in only.project) and (
|
|
312
|
+
only.agent is None or agent in only.agent
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def names(self, rec: SessionRecord) -> tuple[str | None, str | None]:
|
|
316
|
+
"""The session's agent's name and its project's, now."""
|
|
317
|
+
agent = self.registry.get(rec.agent_id)
|
|
318
|
+
if agent is None:
|
|
319
|
+
return None, None
|
|
320
|
+
project = self.projects.get(agent.project_id) if agent.project_id else None
|
|
321
|
+
return agent.name, project.name if project else None
|
|
322
|
+
|
|
323
|
+
def log_path(self, session_id: str) -> Path:
|
|
324
|
+
return self.sessions.folder(session_id) / "events.ndjson"
|
|
325
|
+
|
|
326
|
+
# ── state, problems, what it may touch (I.7) ────────────────────────
|
|
327
|
+
|
|
328
|
+
def update_state(self, name: str, change: Callable[[config.State], None]) -> config.State:
|
|
329
|
+
with self.lock:
|
|
330
|
+
state = config.read_state(self.settings.state_dir, name)
|
|
331
|
+
change(state)
|
|
332
|
+
config.write_state(self.settings.state_dir, name, state)
|
|
333
|
+
return state
|
|
334
|
+
|
|
335
|
+
def find_agent(self, run: AutomationAgentRun) -> AgentRecord:
|
|
336
|
+
"""The agent an agent run names: by its name, in its project (none: of no project)."""
|
|
337
|
+
project = None
|
|
338
|
+
if run.project is not None:
|
|
339
|
+
found = [p for p in self.projects.projects.values() if p.name == run.project]
|
|
340
|
+
if not found:
|
|
341
|
+
raise AutomationError(f"run.project: no project named {run.project}")
|
|
342
|
+
project = found[0].id
|
|
343
|
+
agents = [
|
|
344
|
+
a
|
|
345
|
+
for a in self.registry.agents.values()
|
|
346
|
+
if a.name == run.agent and a.project_id == project
|
|
347
|
+
]
|
|
348
|
+
where = f"in project {run.project}" if run.project else "of no project"
|
|
349
|
+
if not agents:
|
|
350
|
+
raise AutomationError(f"run.agent: no agent named {run.agent} {where}")
|
|
351
|
+
if len(agents) > 1:
|
|
352
|
+
raise AutomationError(f"run.agent: {len(agents)} agents named {run.agent} {where}")
|
|
353
|
+
return agents[0]
|
|
354
|
+
|
|
355
|
+
def agent_problems(self, run: AutomationAgentRun) -> tuple[list[str], list[str]]:
|
|
356
|
+
"""(problems, warnings) of an agent run: the agent, its interface and input, its model
|
|
357
|
+
and the terms of its account's subscription (I.7)."""
|
|
358
|
+
try:
|
|
359
|
+
agent = self.find_agent(run)
|
|
360
|
+
except AutomationError as e:
|
|
361
|
+
return [str(e)], []
|
|
362
|
+
if agent.interface is None:
|
|
363
|
+
return [f"agent {run.agent} has no interface: only those run unattended (D.18)"], []
|
|
364
|
+
found = result_problems(agent.interface.result)
|
|
365
|
+
try:
|
|
366
|
+
self.agent_input(agent, run)
|
|
367
|
+
except AutomationError as e:
|
|
368
|
+
found.append(str(e))
|
|
369
|
+
warnings: list[str] = []
|
|
370
|
+
try:
|
|
371
|
+
runs = checks.session_model(self.settings, agent, None)
|
|
372
|
+
except checks.CheckError as e:
|
|
373
|
+
return [*found, f"agent {run.agent}'s model: {e}"], warnings
|
|
374
|
+
if runs.account is not None:
|
|
375
|
+
login = catalog.get(runs.harness).descriptor.login
|
|
376
|
+
assert login is not None # an account's harness has one
|
|
377
|
+
read = f", read on {login.checked}" if login.checked else ", not read yet"
|
|
378
|
+
terms = f"{login.subscription} terms ({login.terms}{read})"
|
|
379
|
+
if login.unattended == "refused":
|
|
380
|
+
found.append(
|
|
381
|
+
f"agent {run.agent} runs on account {runs.account}: unattended use of a "
|
|
382
|
+
f"{login.subscription} subscription is refused by handler until it is clearly "
|
|
383
|
+
f"allowed by the {terms}; give the agent a provider (an API key) first"
|
|
384
|
+
)
|
|
385
|
+
elif login.unattended == "check-terms":
|
|
386
|
+
warnings.append(
|
|
387
|
+
f"Runs on your subscription (account {runs.account}). Check your "
|
|
388
|
+
f"{login.subscription} plan's terms: automated or unattended use may not be "
|
|
389
|
+
f"allowed ({login.terms}). An API key (a provider) is recommended."
|
|
390
|
+
)
|
|
391
|
+
return found, warnings
|
|
392
|
+
|
|
393
|
+
def agent_input(self, agent: AgentRecord, run: AutomationAgentRun) -> SessionInput:
|
|
394
|
+
assert agent.interface is not None
|
|
395
|
+
given = SessionInput.model_validate(run.input.model_dump(by_alias=True))
|
|
396
|
+
try:
|
|
397
|
+
return interfaces.check_input(agent.interface, given, self.settings.skills_dir)
|
|
398
|
+
except interfaces.InterfaceError as e:
|
|
399
|
+
raise AutomationError(f"run.input: {e}") from None
|
|
400
|
+
|
|
401
|
+
def checked(self, loaded: config.Loaded) -> tuple[list[str], list[str]]:
|
|
402
|
+
"""(problems, warnings): what keeps it from running (I.1), what to know first (I.7)."""
|
|
403
|
+
root, _ = knowledge.root(self.settings)
|
|
404
|
+
found = config.problems(
|
|
405
|
+
loaded,
|
|
406
|
+
providers_dir=self.settings.providers_dir,
|
|
407
|
+
store=secrets.SecretStore(self.settings.data_dir),
|
|
408
|
+
knowledge_root=root,
|
|
409
|
+
projects={p.name for p in self.projects.projects.values()},
|
|
410
|
+
agents={a.name for a in self.registry.agents.values() if a.name},
|
|
411
|
+
)
|
|
412
|
+
definition, warnings = loaded.definition, []
|
|
413
|
+
if definition is not None and (run := definition.agent_run) is not None:
|
|
414
|
+
more, warnings = self.agent_problems(run)
|
|
415
|
+
found += more
|
|
416
|
+
call = definition.app_call if definition is not None else None
|
|
417
|
+
if call is not None and call.app not in self.apps.records:
|
|
418
|
+
found.append(f"run.app: no app named {call.app}")
|
|
419
|
+
return found, warnings
|
|
420
|
+
|
|
421
|
+
def problems(self, loaded: config.Loaded) -> list[str]:
|
|
422
|
+
return self.checked(loaded)[0]
|
|
423
|
+
|
|
424
|
+
def permissions(self, definition: Automation) -> list[str]:
|
|
425
|
+
"""What it may touch and what starts it, in sentences (I.7)."""
|
|
426
|
+
if (schedule := definition.schedule) is not None:
|
|
427
|
+
days = f" on {', '.join(schedule.days)}" if schedule.days else ""
|
|
428
|
+
when = (
|
|
429
|
+
f"Starts every {schedule.every}"
|
|
430
|
+
if schedule.every
|
|
431
|
+
else f"Starts at {schedule.at} (local time){days}"
|
|
432
|
+
)
|
|
433
|
+
when += "; after the computer was off, only the latest missed slot runs."
|
|
434
|
+
else:
|
|
435
|
+
only = definition.only
|
|
436
|
+
whose = [
|
|
437
|
+
f"{kind} {', '.join(names)}"
|
|
438
|
+
for kind, names in (
|
|
439
|
+
("of project", only.project if only else None),
|
|
440
|
+
("of agent", only.agent if only else None),
|
|
441
|
+
)
|
|
442
|
+
if names
|
|
443
|
+
]
|
|
444
|
+
when = "Starts when a session " + (" ".join(whose) + " " if whose else "") + "stops."
|
|
445
|
+
found = [when]
|
|
446
|
+
if (script := definition.script) is not None:
|
|
447
|
+
reads = (
|
|
448
|
+
"the conversation of the session that stopped"
|
|
449
|
+
if definition.schedule is None
|
|
450
|
+
else "nothing but the time it runs"
|
|
451
|
+
)
|
|
452
|
+
found.append(f"Runs `{script}` in a sandbox without network, reading {reads}.")
|
|
453
|
+
if definition.services and definition.services.llm:
|
|
454
|
+
found.append(f"May call the models {', '.join(definition.services.llm)}.")
|
|
455
|
+
if definition.append:
|
|
456
|
+
notes = ", ".join(a.to for a in definition.append)
|
|
457
|
+
found.append(f"Appends what it writes to the notes {notes}.")
|
|
458
|
+
found.append("Tells your inbox when a run fails.")
|
|
459
|
+
elif (run := definition.agent_run) is not None:
|
|
460
|
+
where = f"of project {run.project}" if run.project else "of no project"
|
|
461
|
+
found.append(f"Runs the agent {run.agent} {where} unattended, without asking you.")
|
|
462
|
+
with contextlib.suppress(AutomationError):
|
|
463
|
+
agent = self.find_agent(run)
|
|
464
|
+
folder = agent.scope or "a folder of its own for each run"
|
|
465
|
+
runs = agent.models[0] if agent.models else None
|
|
466
|
+
model = (
|
|
467
|
+
" · ".join(
|
|
468
|
+
x for x in (runs.harness, runs.account or runs.provider, runs.model) if x
|
|
469
|
+
)
|
|
470
|
+
if runs
|
|
471
|
+
else "none"
|
|
472
|
+
)
|
|
473
|
+
found.append(
|
|
474
|
+
f"It works in {folder} (read-write), network {agent.network}, "
|
|
475
|
+
f"confinement {agent.confinement}, on {model}."
|
|
476
|
+
)
|
|
477
|
+
found.append("Sends its result to your inbox.")
|
|
478
|
+
elif (call := definition.app_call) is not None:
|
|
479
|
+
found.append(f"Calls {call.call} of the app {call.app}, as you.")
|
|
480
|
+
found.append("Tells your inbox when a call fails.")
|
|
481
|
+
return found
|
|
482
|
+
|
|
483
|
+
def get(self, name: str) -> config.Loaded:
|
|
484
|
+
loaded = config.get(self.settings.automations_dir, name)
|
|
485
|
+
if loaded is None:
|
|
486
|
+
raise AutomationNotFoundError(f"no automation named {name}")
|
|
487
|
+
return loaded
|
|
488
|
+
|
|
489
|
+
def view(self, name: str) -> AutomationView:
|
|
490
|
+
loaded = self.get(name)
|
|
491
|
+
state = config.read_state(self.settings.state_dir, name)
|
|
492
|
+
definition = loaded.definition
|
|
493
|
+
problems, warnings = self.checked(loaded)
|
|
494
|
+
runs = self.runs(name)
|
|
495
|
+
next_at = None
|
|
496
|
+
if state.enabled and definition is not None and definition.schedule is not None:
|
|
497
|
+
last = state.last_slot or state.since
|
|
498
|
+
if last is not None:
|
|
499
|
+
next_at = schedules.next_slot(definition.schedule, last, now())
|
|
500
|
+
pending = sum(1 for i in self.queue.values() if i.automation == name and not i.trial)
|
|
501
|
+
busy = self.running.record.automation if self.running else None
|
|
502
|
+
return AutomationView(
|
|
503
|
+
name=name,
|
|
504
|
+
description=definition.description if definition else None,
|
|
505
|
+
definition=definition,
|
|
506
|
+
directory=str(loaded.path),
|
|
507
|
+
files=config.files(loaded.path),
|
|
508
|
+
problems=problems,
|
|
509
|
+
enabled=state.enabled,
|
|
510
|
+
permissions=self.permissions(definition) if definition else [],
|
|
511
|
+
warnings=warnings,
|
|
512
|
+
next_at=next_at,
|
|
513
|
+
pending=pending,
|
|
514
|
+
behind=busy if pending and busy != name else None,
|
|
515
|
+
capped=self.capped(name),
|
|
516
|
+
last_run=runs[0] if runs else None,
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
def views(self) -> list[AutomationView]:
|
|
520
|
+
return [self.view(a.name) for a in config.load_all(self.settings.automations_dir)]
|
|
521
|
+
|
|
522
|
+
def enable(self, name: str) -> AutomationView:
|
|
523
|
+
"""Turned on: the owner's only approval (I.7). Stops and slots before are not read."""
|
|
524
|
+
loaded = self.get(name)
|
|
525
|
+
if loaded.definition is None or (found := self.problems(loaded)):
|
|
526
|
+
raise AutomationConflictError(f"{name} has problems: {'; '.join(found)}")
|
|
527
|
+
|
|
528
|
+
def on(state: config.State) -> None:
|
|
529
|
+
if not state.enabled:
|
|
530
|
+
state.since, state.last_slot = now(), None
|
|
531
|
+
state.enabled = True
|
|
532
|
+
|
|
533
|
+
self.update_state(name, on)
|
|
534
|
+
return self.view(name)
|
|
535
|
+
|
|
536
|
+
def disable(self, name: str) -> AutomationView:
|
|
537
|
+
self.get(name)
|
|
538
|
+
|
|
539
|
+
def off(state: config.State) -> None:
|
|
540
|
+
state.enabled = False
|
|
541
|
+
|
|
542
|
+
self.update_state(name, off)
|
|
543
|
+
for key in [k for k, i in self.queue.items() if i.automation == name and not i.trial]:
|
|
544
|
+
del self.queue[key]
|
|
545
|
+
return self.view(name)
|
|
546
|
+
|
|
547
|
+
def create(
|
|
548
|
+
self,
|
|
549
|
+
name: str,
|
|
550
|
+
*,
|
|
551
|
+
example: str | None = None,
|
|
552
|
+
definition: Automation | None = None,
|
|
553
|
+
enable: bool = False,
|
|
554
|
+
) -> AutomationView:
|
|
555
|
+
"""A new one, off: an example copied, or `definition` written (I.1); `enable`: then
|
|
556
|
+
turned on, when it has no problems (409 else, and it stays, off)."""
|
|
557
|
+
directory = self.settings.automations_dir
|
|
558
|
+
try:
|
|
559
|
+
if definition is not None:
|
|
560
|
+
config.write_new(directory, name, definition)
|
|
561
|
+
else:
|
|
562
|
+
assert example is not None
|
|
563
|
+
config.copy_example(directory, name, example)
|
|
564
|
+
except config.AutomationExistsError as e:
|
|
565
|
+
raise AutomationConflictError(str(e)) from None
|
|
566
|
+
except config.AutomationConfigError as e:
|
|
567
|
+
raise AutomationNotFoundError(str(e)) from None
|
|
568
|
+
return self.enable(name) if enable else self.view(name)
|
|
569
|
+
|
|
570
|
+
def update(self, name: str, definition: Automation) -> AutomationView:
|
|
571
|
+
"""`definition` written over its automation.toml (the owner's edit, as in an editor: on,
|
|
572
|
+
it stays on and the edit applies at its next run, I.7)."""
|
|
573
|
+
config.write_definition(self.get(name).path, definition)
|
|
574
|
+
return self.view(name)
|
|
575
|
+
|
|
576
|
+
def preview(self, definition: Automation) -> AutomationCheck:
|
|
577
|
+
"""What `definition` would do, its problems and warnings, before it is saved."""
|
|
578
|
+
problems, warnings = self.checked(config.Loaded("", Path(), definition))
|
|
579
|
+
at, schedule = now(), definition.schedule
|
|
580
|
+
return AutomationCheck(
|
|
581
|
+
problems=problems,
|
|
582
|
+
warnings=warnings,
|
|
583
|
+
permissions=self.permissions(definition),
|
|
584
|
+
next_at=schedules.next_slot(schedule, at, at) if schedule is not None else None,
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
async def delete(self, name: str) -> None:
|
|
588
|
+
"""Gone: its waiting runs dropped, the one in progress stopped (and waited for, so that
|
|
589
|
+
nothing it writes brings its state back), its folder and its state (runs, logs) removed.
|
|
590
|
+
What it told the inbox stays."""
|
|
591
|
+
loaded = self.get(name)
|
|
592
|
+
for key in [k for k, i in self.queue.items() if i.automation == name]:
|
|
593
|
+
del self.queue[key]
|
|
594
|
+
running = self.running
|
|
595
|
+
if running is not None and running.record.automation == name:
|
|
596
|
+
await self.cancel(name, running.record.id)
|
|
597
|
+
loop = asyncio.get_running_loop()
|
|
598
|
+
deadline = loop.time() + DELETE_WAIT
|
|
599
|
+
while self.running is running and loop.time() < deadline:
|
|
600
|
+
await asyncio.sleep(0.1)
|
|
601
|
+
await asyncio.to_thread(shutil.rmtree, loaded.path)
|
|
602
|
+
state = config.state_dir(self.settings.state_dir, name)
|
|
603
|
+
await asyncio.to_thread(shutil.rmtree, state, ignore_errors=True)
|
|
604
|
+
|
|
605
|
+
# ── runs ────────────────────────────────────────────────────────────
|
|
606
|
+
|
|
607
|
+
def runs_dir(self, name: str) -> Path:
|
|
608
|
+
return config.state_dir(self.settings.state_dir, name) / "runs"
|
|
609
|
+
|
|
610
|
+
def runs(self, name: str) -> list[AutomationRun]:
|
|
611
|
+
"""Most recent first."""
|
|
612
|
+
found = []
|
|
613
|
+
for path in self.runs_dir(name).glob("a_*/run.json"):
|
|
614
|
+
with contextlib.suppress(OSError, ValueError):
|
|
615
|
+
found.append(AutomationRun.model_validate_json(path.read_text()))
|
|
616
|
+
return sorted(found, key=lambda r: r.created_at, reverse=True)
|
|
617
|
+
|
|
618
|
+
def runs_of_session(self, session_id: str) -> list[AutomationRun]:
|
|
619
|
+
found = [
|
|
620
|
+
r
|
|
621
|
+
for loaded in config.load_all(self.settings.automations_dir)
|
|
622
|
+
for r in self.runs(loaded.name)
|
|
623
|
+
if session_id in (r.session_id, r.agent_session_id)
|
|
624
|
+
]
|
|
625
|
+
return sorted(found, key=lambda r: r.created_at, reverse=True)
|
|
626
|
+
|
|
627
|
+
def get_run(self, name: str, run_id: str) -> AutomationRun:
|
|
628
|
+
self.get(name)
|
|
629
|
+
path = self.runs_dir(name) / run_id / "run.json"
|
|
630
|
+
if "/" in run_id or not run_id.startswith("a_") or not path.is_file():
|
|
631
|
+
raise AutomationNotFoundError(f"no run {run_id} of {name}")
|
|
632
|
+
if self.running and self.running.record.id == run_id:
|
|
633
|
+
return self.running.record
|
|
634
|
+
return AutomationRun.model_validate_json(path.read_text())
|
|
635
|
+
|
|
636
|
+
def run_log(self, name: str, run_id: str) -> str:
|
|
637
|
+
self.get_run(name, run_id)
|
|
638
|
+
path = self.runs_dir(name) / run_id / "run.log"
|
|
639
|
+
return path.read_text(errors="replace") if path.is_file() else ""
|
|
640
|
+
|
|
641
|
+
def write_record(self, record: AutomationRun) -> None:
|
|
642
|
+
folder = self.runs_dir(record.automation) / record.id
|
|
643
|
+
folder.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
644
|
+
tmp = folder / "run.json.tmp"
|
|
645
|
+
tmp.write_text(record.model_dump_json(by_alias=True, exclude_none=True, indent=2))
|
|
646
|
+
tmp.replace(folder / "run.json")
|
|
647
|
+
|
|
648
|
+
def try_run(self, name: str, session_id: str | None) -> AutomationRun:
|
|
649
|
+
"""I.8: now, on or off; on a stop's whole log; appends nothing."""
|
|
650
|
+
loaded = self.get(name)
|
|
651
|
+
if loaded.definition is None or (found := self.problems(loaded)):
|
|
652
|
+
raise AutomationConflictError(f"{name} has problems: {'; '.join(found)}")
|
|
653
|
+
on_stop = loaded.definition.when == "session.stopped"
|
|
654
|
+
if on_stop and session_id is None:
|
|
655
|
+
raise AutomationConflictError(f"{name} runs on a session that stopped: give one")
|
|
656
|
+
if not on_stop and session_id is not None:
|
|
657
|
+
raise AutomationConflictError(f"{name} runs on a schedule: no session")
|
|
658
|
+
if session_id is not None and session_id not in self.sessions.records:
|
|
659
|
+
raise AutomationNotFoundError(f"no session {session_id}")
|
|
660
|
+
if self.capped(name):
|
|
661
|
+
raise AutomationCappedError(f"{name} ran {DAILY_CAP} times today")
|
|
662
|
+
record = AutomationRun(
|
|
663
|
+
id=new_run_id(), automation=name, trial=True, state="queued", created_at=now()
|
|
664
|
+
)
|
|
665
|
+
if session_id is not None:
|
|
666
|
+
events = transcript.read(self.log_path(session_id), 0, LAST)
|
|
667
|
+
seq_to = events[-1]["seq"] if events else 0
|
|
668
|
+
record = record.model_copy(
|
|
669
|
+
update={"session_id": session_id, "seq_from": 1, "seq_to": seq_to}
|
|
670
|
+
)
|
|
671
|
+
self.write_record(record)
|
|
672
|
+
item = Item(name, session_id, trial=record)
|
|
673
|
+
self.queue[item.key] = item
|
|
674
|
+
self.wake.set()
|
|
675
|
+
return record
|
|
676
|
+
|
|
677
|
+
async def cancel(self, name: str, run_id: str) -> AutomationRun:
|
|
678
|
+
record = self.get_run(name, run_id)
|
|
679
|
+
if (key := (name, record.session_id or "", run_id)) in self.queue:
|
|
680
|
+
del self.queue[key]
|
|
681
|
+
record = record.model_copy(update={"state": "cancelled", "ended_at": now()})
|
|
682
|
+
self.write_record(record)
|
|
683
|
+
return record
|
|
684
|
+
running = self.running
|
|
685
|
+
if running is None or running.record.id != run_id:
|
|
686
|
+
raise AutomationConflictError(f"run {run_id} has ended ({record.state})")
|
|
687
|
+
running.cancelled = True
|
|
688
|
+
if running.proc is not None:
|
|
689
|
+
await sandbox_process.stop(running.proc)
|
|
690
|
+
if (sid := running.record.agent_session_id) is not None:
|
|
691
|
+
await self.sessions.stop(sid, "its automation run was cancelled", "explicit")
|
|
692
|
+
return running.record
|
|
693
|
+
|
|
694
|
+
async def run(self, item: Item) -> None:
|
|
695
|
+
loaded = config.get(self.settings.automations_dir, item.automation)
|
|
696
|
+
rec = self.sessions.records.get(item.session_id) if item.session_id else None
|
|
697
|
+
if loaded is None or loaded.definition is None or (item.session_id and rec is None):
|
|
698
|
+
if item.trial:
|
|
699
|
+
self.end(item.trial, "failed", error="the automation or the session is gone")
|
|
700
|
+
return
|
|
701
|
+
events: list[dict[str, Any]] = []
|
|
702
|
+
if item.session_id is not None:
|
|
703
|
+
events = await asyncio.to_thread(
|
|
704
|
+
transcript.read, self.log_path(item.session_id), 0, LAST
|
|
705
|
+
)
|
|
706
|
+
record = self.new_record(loaded, item, events)
|
|
707
|
+
if record is None:
|
|
708
|
+
return
|
|
709
|
+
self.count_start(loaded.name)
|
|
710
|
+
self.running = Running(record)
|
|
711
|
+
self.write_record(record)
|
|
712
|
+
try:
|
|
713
|
+
await self.execute(loaded, rec, events, self.running)
|
|
714
|
+
except asyncio.CancelledError: # handlerd stops: a stop's part is read again at its start
|
|
715
|
+
if self.running.proc is not None:
|
|
716
|
+
await sandbox_process.stop(self.running.proc)
|
|
717
|
+
self.end(self.running.record, "interrupted", move=False)
|
|
718
|
+
raise
|
|
719
|
+
except Exception as e: # handlerd's side failed (a file, the sandbox's executable)
|
|
720
|
+
log.exception("automation %s: run %s", loaded.name, record.id)
|
|
721
|
+
self.end(self.running.record, "failed", error=f"handler could not run it: {e}")
|
|
722
|
+
finally:
|
|
723
|
+
self.running = None
|
|
724
|
+
|
|
725
|
+
def new_record(
|
|
726
|
+
self, loaded: config.Loaded, item: Item, events: list[dict[str, Any]]
|
|
727
|
+
) -> AutomationRun | None:
|
|
728
|
+
"""The record of the run starting now; None when it does not run after all: turned off or
|
|
729
|
+
with problems meanwhile (read once it is on again without problems), nothing new read.
|
|
730
|
+
A schedule's slot is taken now: a run cut by handlerd stopping is not run again."""
|
|
731
|
+
if item.trial is not None:
|
|
732
|
+
return item.trial.model_copy(update={"state": "running"})
|
|
733
|
+
state = config.read_state(self.settings.state_dir, loaded.name)
|
|
734
|
+
if not state.enabled or self.problems(loaded):
|
|
735
|
+
return None
|
|
736
|
+
record = AutomationRun(
|
|
737
|
+
id=new_run_id(), automation=loaded.name, state="running", created_at=now()
|
|
738
|
+
)
|
|
739
|
+
if item.session_id is not None:
|
|
740
|
+
cursor = state.cursors.get(item.session_id, 0)
|
|
741
|
+
if not pending_stop(events, cursor, state.since):
|
|
742
|
+
return None
|
|
743
|
+
seqs = {"seq_from": cursor + 1, "seq_to": events[-1]["seq"]}
|
|
744
|
+
return record.model_copy(update={"session_id": item.session_id} | seqs)
|
|
745
|
+
slot = item.slot
|
|
746
|
+
|
|
747
|
+
def ran(s: config.State) -> None:
|
|
748
|
+
s.last_slot = slot
|
|
749
|
+
|
|
750
|
+
self.update_state(loaded.name, ran)
|
|
751
|
+
return record.model_copy(update={"slot": slot, "missed": item.missed})
|
|
752
|
+
|
|
753
|
+
async def execute(
|
|
754
|
+
self,
|
|
755
|
+
loaded: config.Loaded,
|
|
756
|
+
rec: SessionRecord | None,
|
|
757
|
+
events: list[dict[str, Any]],
|
|
758
|
+
running: Running,
|
|
759
|
+
) -> None:
|
|
760
|
+
definition = loaded.definition
|
|
761
|
+
assert definition is not None
|
|
762
|
+
if (run := definition.agent_run) is not None:
|
|
763
|
+
await self.execute_agent(loaded, run, running)
|
|
764
|
+
elif (call := definition.app_call) is not None:
|
|
765
|
+
loop = asyncio.get_running_loop()
|
|
766
|
+
started = loop.time()
|
|
767
|
+
try:
|
|
768
|
+
status = await self.apps.call(call.app, call.call, loaded.name)
|
|
769
|
+
except AppError as e:
|
|
770
|
+
self.end(running.record, "failed", error=f"app {call.app}: {e}")
|
|
771
|
+
return
|
|
772
|
+
running.record = running.record.model_copy(
|
|
773
|
+
update={"ms": int((loop.time() - started) * 1000)}
|
|
774
|
+
)
|
|
775
|
+
if status >= 400:
|
|
776
|
+
self.end(running.record, "failed", error=f"{call.call}: HTTP {status}")
|
|
777
|
+
else:
|
|
778
|
+
self.end(running.record, "succeeded")
|
|
779
|
+
else:
|
|
780
|
+
await self.execute_script(loaded, rec, events, running)
|
|
781
|
+
|
|
782
|
+
async def execute_agent(
|
|
783
|
+
self, loaded: config.Loaded, run: AutomationAgentRun, running: Running
|
|
784
|
+
) -> None:
|
|
785
|
+
"""A run of the agent (D.18), watched until its session stops; its result → the inbox."""
|
|
786
|
+
loop = asyncio.get_running_loop()
|
|
787
|
+
started = loop.time()
|
|
788
|
+
try:
|
|
789
|
+
agent = self.find_agent(run)
|
|
790
|
+
given = self.agent_input(agent, run)
|
|
791
|
+
runs = checks.session_model(self.settings, agent, None)
|
|
792
|
+
except (AutomationError, checks.CheckError) as e:
|
|
793
|
+
self.end(running.record, "failed", error=str(e))
|
|
794
|
+
return
|
|
795
|
+
if running.cancelled: # before its session: nothing to stop, nothing to start
|
|
796
|
+
self.end(running.record, "cancelled")
|
|
797
|
+
return
|
|
798
|
+
session = self.sessions.create(agent, runs, origin="automation", run=given)
|
|
799
|
+
running.record = running.record.model_copy(update={"agent_session_id": session.id})
|
|
800
|
+
self.write_record(running.record)
|
|
801
|
+
while True:
|
|
802
|
+
await asyncio.sleep(SESSION_POLL)
|
|
803
|
+
rec = self.sessions.records.get(session.id)
|
|
804
|
+
if rec is None:
|
|
805
|
+
self.end(running.record, "failed", error="its session was deleted")
|
|
806
|
+
return
|
|
807
|
+
busy = session.id in self.sessions.live or session.id in self.sessions.starting
|
|
808
|
+
if rec.state in ("stopped", "failed") and not busy:
|
|
809
|
+
break
|
|
810
|
+
running.record = running.record.model_copy(
|
|
811
|
+
update={"ms": int((loop.time() - started) * 1000)}
|
|
812
|
+
)
|
|
813
|
+
output = SessionOutput.model_validate(rec.output) if rec.output else None
|
|
814
|
+
if running.cancelled:
|
|
815
|
+
self.end(running.record, "cancelled")
|
|
816
|
+
elif output is not None and output.valid and isinstance(output.value, dict):
|
|
817
|
+
value: dict[str, Any] = output.value
|
|
818
|
+
self.end(running.record, "succeeded", result=value)
|
|
819
|
+
else:
|
|
820
|
+
why = (
|
|
821
|
+
(output.error if output else None)
|
|
822
|
+
or rec.error
|
|
823
|
+
or f"its turn ended: {rec.last_stop_reason or 'no result'}"
|
|
824
|
+
)
|
|
825
|
+
self.end(running.record, "failed", error=f"no valid result: {why}")
|
|
826
|
+
|
|
827
|
+
async def execute_script(
|
|
828
|
+
self,
|
|
829
|
+
loaded: config.Loaded,
|
|
830
|
+
rec: SessionRecord | None,
|
|
831
|
+
events: list[dict[str, Any]],
|
|
832
|
+
running: Running,
|
|
833
|
+
) -> None:
|
|
834
|
+
definition = loaded.definition
|
|
835
|
+
assert definition is not None and definition.script is not None
|
|
836
|
+
record = running.record
|
|
837
|
+
folder = self.runs_dir(loaded.name) / record.id
|
|
838
|
+
redact = secrets.redactor(secrets.SecretStore(self.settings.data_dir).values())
|
|
839
|
+
if rec is not None and record.seq_from is not None and record.seq_to is not None:
|
|
840
|
+
slice_ = [e for e in events if record.seq_from <= e["seq"] <= record.seq_to]
|
|
841
|
+
await asyncio.to_thread(self.prepare, loaded, rec, slice_, record, redact)
|
|
842
|
+
else:
|
|
843
|
+
await asyncio.to_thread(self.prepare_scheduled, loaded, record)
|
|
844
|
+
if running.cancelled:
|
|
845
|
+
self.end(record, "cancelled")
|
|
846
|
+
return
|
|
847
|
+
toolbox = images.ref(images.TOOLBOX, images.LATEST)
|
|
848
|
+
if not await asyncio.to_thread(runtime.exists, toolbox):
|
|
849
|
+
self.end(record, "failed", error="toolbox not built: `handler image rebuild toolbox`")
|
|
850
|
+
return
|
|
851
|
+
code, timed_out = await self.sandboxed(definition, toolbox, folder, running, redact)
|
|
852
|
+
record = running.record
|
|
853
|
+
if running.cancelled:
|
|
854
|
+
self.end(record, "cancelled")
|
|
855
|
+
elif timed_out:
|
|
856
|
+
timeout = definition.timeout_sec or DEFAULT_TIMEOUT_SEC
|
|
857
|
+
self.end(record, "failed", error=f"timed out after {timeout} s")
|
|
858
|
+
elif code != 0:
|
|
859
|
+
self.end(record, "failed", error=f"the script exited with {code}")
|
|
860
|
+
else:
|
|
861
|
+
try:
|
|
862
|
+
appended = await asyncio.to_thread(self.append, loaded, record, folder / "output")
|
|
863
|
+
except AutomationError as e:
|
|
864
|
+
self.end(record, "failed", error=str(e))
|
|
865
|
+
else:
|
|
866
|
+
self.end(record, "succeeded", appended=appended)
|
|
867
|
+
|
|
868
|
+
async def sandboxed(
|
|
869
|
+
self,
|
|
870
|
+
definition: Automation,
|
|
871
|
+
toolbox: str,
|
|
872
|
+
folder: Path,
|
|
873
|
+
running: Running,
|
|
874
|
+
redact: Callable[[str], str],
|
|
875
|
+
) -> tuple[int | None, bool]:
|
|
876
|
+
"""The run's sandbox (I.3), with its services socket (I.5) when it calls models, until
|
|
877
|
+
it ends or its timeout: (its exit code, timed out). Its record gets what it used."""
|
|
878
|
+
assert definition.script is not None
|
|
879
|
+
record = running.record
|
|
880
|
+
grant, server, socket_dir = None, None, None
|
|
881
|
+
if models := definition.services.llm if definition.services else []:
|
|
882
|
+
socket_dir = self.settings.runtime_dir / "automations" / record.id
|
|
883
|
+
socket_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
884
|
+
grant = service_router.Grant(llm=frozenset(models), log=folder / "services.ndjson")
|
|
885
|
+
server = await service_router.serve(self.settings, socket_dir / "services.sock", grant)
|
|
886
|
+
spec = compose.automation_run_spec(
|
|
887
|
+
name=f"handler-run-{record.id.replace('_', '-')}",
|
|
888
|
+
run_id=record.id,
|
|
889
|
+
toolbox=toolbox,
|
|
890
|
+
run=definition.script,
|
|
891
|
+
definition=str(folder / "definition"),
|
|
892
|
+
input_dir=str(folder / "input"),
|
|
893
|
+
output=str(folder / "output"),
|
|
894
|
+
bin_dir=str(BIN),
|
|
895
|
+
services=str(socket_dir / "services.sock") if socket_dir else None,
|
|
896
|
+
trial=record.trial,
|
|
897
|
+
)
|
|
898
|
+
loop = asyncio.get_running_loop()
|
|
899
|
+
started, timed_out = loop.time(), False
|
|
900
|
+
try:
|
|
901
|
+
running.proc = proc = await sandbox_process.start_piped(spec, line_limit=LINE_LIMIT)
|
|
902
|
+
copying = asyncio.create_task(copy_log(proc, folder / "run.log", redact))
|
|
903
|
+
if running.cancelled: # while it was being set up: there was nothing to stop yet
|
|
904
|
+
await sandbox_process.stop(proc)
|
|
905
|
+
try:
|
|
906
|
+
await asyncio.wait_for(proc.wait(), definition.timeout_sec or DEFAULT_TIMEOUT_SEC)
|
|
907
|
+
except TimeoutError:
|
|
908
|
+
timed_out = True
|
|
909
|
+
await sandbox_process.stop(proc)
|
|
910
|
+
await copying
|
|
911
|
+
finally:
|
|
912
|
+
if server is not None:
|
|
913
|
+
server.close()
|
|
914
|
+
if socket_dir is not None:
|
|
915
|
+
shutil.rmtree(socket_dir, ignore_errors=True)
|
|
916
|
+
used: dict[str, Any] = {
|
|
917
|
+
"exit_code": proc.returncode,
|
|
918
|
+
"ms": int((loop.time() - started) * 1000),
|
|
919
|
+
}
|
|
920
|
+
if grant is not None:
|
|
921
|
+
used["llm"] = AutomationLlmUsage(
|
|
922
|
+
calls=grant.calls, in_tokens=grant.in_tokens, out_tokens=grant.out_tokens
|
|
923
|
+
)
|
|
924
|
+
running.record = record.model_copy(update=used)
|
|
925
|
+
return proc.returncode, timed_out
|
|
926
|
+
|
|
927
|
+
def copy_definition(self, loaded: config.Loaded, record: AutomationRun) -> Path:
|
|
928
|
+
"""The run's folder (I.3): a copy of the automation's, an empty input and output."""
|
|
929
|
+
folder = self.runs_dir(loaded.name) / record.id
|
|
930
|
+
shutil.copytree(
|
|
931
|
+
loaded.path,
|
|
932
|
+
folder / "definition",
|
|
933
|
+
ignore=shutil.ignore_patterns(".*", "__pycache__"),
|
|
934
|
+
dirs_exist_ok=True,
|
|
935
|
+
)
|
|
936
|
+
(folder / "output").mkdir(exist_ok=True)
|
|
937
|
+
(folder / "input").mkdir(exist_ok=True)
|
|
938
|
+
return folder
|
|
939
|
+
|
|
940
|
+
def prepare_scheduled(self, loaded: config.Loaded, record: AutomationRun) -> None:
|
|
941
|
+
"""A scheduled script's input (I.4): its slot only."""
|
|
942
|
+
folder = self.copy_definition(loaded, record)
|
|
943
|
+
event = {
|
|
944
|
+
"type": "schedule",
|
|
945
|
+
"slot": record.slot.isoformat() if record.slot else None,
|
|
946
|
+
"missed": record.missed,
|
|
947
|
+
"t": now().isoformat(),
|
|
948
|
+
}
|
|
949
|
+
(folder / "input" / "event.json").write_text(json.dumps(event, indent=2) + "\n")
|
|
950
|
+
|
|
951
|
+
def prepare(
|
|
952
|
+
self,
|
|
953
|
+
loaded: config.Loaded,
|
|
954
|
+
rec: SessionRecord,
|
|
955
|
+
events: list[dict[str, Any]],
|
|
956
|
+
record: AutomationRun,
|
|
957
|
+
redact: Callable[[str], str],
|
|
958
|
+
) -> None:
|
|
959
|
+
"""A stop's run folder (I.3, I.4): its input is the session's part read."""
|
|
960
|
+
given = self.copy_definition(loaded, record) / "input"
|
|
961
|
+
agent = self.registry.get(rec.agent_id)
|
|
962
|
+
agent_name, project_name = self.names(rec)
|
|
963
|
+
stops = [e for e in events if e.get("kind") == "lifecycle" and e.get("event") == "stopped"]
|
|
964
|
+
event = {
|
|
965
|
+
"type": "session.stopped",
|
|
966
|
+
"sessionId": rec.id,
|
|
967
|
+
"agentId": rec.agent_id,
|
|
968
|
+
"agent": agent_name,
|
|
969
|
+
"projectId": agent.project_id if agent else None,
|
|
970
|
+
"project": project_name,
|
|
971
|
+
"how": stops[-1].get("how") if stops else None,
|
|
972
|
+
"seqFrom": record.seq_from,
|
|
973
|
+
"seqTo": record.seq_to,
|
|
974
|
+
"t": stops[-1]["t"] if stops else now().isoformat(),
|
|
975
|
+
}
|
|
976
|
+
session = self.sessions.to_contract(rec).model_dump(mode="json", by_alias=True)
|
|
977
|
+
settings: dict[str, Any] = {"name": agent_name}
|
|
978
|
+
if agent is not None:
|
|
979
|
+
settings |= agent.settings.model_dump(
|
|
980
|
+
mode="json",
|
|
981
|
+
by_alias=True,
|
|
982
|
+
include={"network", "allow", "confinement", "permissions", "bindings", "knowledge"},
|
|
983
|
+
)
|
|
984
|
+
session["agent"] = settings
|
|
985
|
+
model = " · ".join(x for x in (rec.harness, rec.account or rec.provider, rec.model) if x)
|
|
986
|
+
header = [
|
|
987
|
+
("Agent", agent_name or rec.harness),
|
|
988
|
+
("Project", project_name or "none (an ad-hoc agent)"),
|
|
989
|
+
("Model", model),
|
|
990
|
+
("Folder", rec.scope),
|
|
991
|
+
("Network", f"{settings.get('network')} {' '.join(settings.get('allow') or [])}"),
|
|
992
|
+
("Confinement", str(settings.get("confinement"))),
|
|
993
|
+
("Permissions", str(settings.get("permissions"))),
|
|
994
|
+
("Addons", ", ".join(settings.get("bindings") or []) or "none"),
|
|
995
|
+
("Knowledge", ", ".join(settings.get("knowledge") or []) or "none"),
|
|
996
|
+
("Events", f"{record.seq_from} to {record.seq_to}"),
|
|
997
|
+
]
|
|
998
|
+
files = {
|
|
999
|
+
"event.json": json.dumps(event, indent=2) + "\n",
|
|
1000
|
+
"session.json": json.dumps(session, indent=2) + "\n",
|
|
1001
|
+
"events.ndjson": "".join(json.dumps(e) + "\n" for e in events),
|
|
1002
|
+
"transcript.md": transcript.render(events, header),
|
|
1003
|
+
}
|
|
1004
|
+
for file, text in files.items():
|
|
1005
|
+
(given / file).write_text(redact(text))
|
|
1006
|
+
|
|
1007
|
+
def append(
|
|
1008
|
+
self, loaded: config.Loaded, record: AutomationRun, output: Path
|
|
1009
|
+
) -> list[AutomationAppended]:
|
|
1010
|
+
"""I.6: each `from` file the run wrote to its note, in order, once every one was
|
|
1011
|
+
checked (a try: what would be appended, nothing written)."""
|
|
1012
|
+
assert loaded.definition is not None
|
|
1013
|
+
root, _ = knowledge.root(self.settings)
|
|
1014
|
+
base = os.path.realpath(output)
|
|
1015
|
+
blocks = []
|
|
1016
|
+
for append in loaded.definition.append:
|
|
1017
|
+
path = os.path.realpath(os.path.join(base, append.from_))
|
|
1018
|
+
if not path.startswith(base + "/"):
|
|
1019
|
+
raise AutomationError(f"{append.from_}: outside the run's output folder")
|
|
1020
|
+
if not os.path.lexists(path):
|
|
1021
|
+
continue # nothing to add
|
|
1022
|
+
if not os.path.isfile(path):
|
|
1023
|
+
raise AutomationError(f"{append.from_}: not a file")
|
|
1024
|
+
if os.path.getsize(path) > MAX_APPEND:
|
|
1025
|
+
raise AutomationError(f"{append.from_}: larger than {MAX_APPEND // 1024} KiB")
|
|
1026
|
+
try:
|
|
1027
|
+
knowledge.check_note(root, append.to)
|
|
1028
|
+
except knowledge.KnowledgeError as e:
|
|
1029
|
+
raise AutomationError(str(e)) from None
|
|
1030
|
+
text = Path(path).read_text(encoding="utf-8", errors="replace")
|
|
1031
|
+
blocks.append((append.to, text))
|
|
1032
|
+
appended = []
|
|
1033
|
+
for to, text in blocks:
|
|
1034
|
+
if record.trial:
|
|
1035
|
+
size = len(text.encode())
|
|
1036
|
+
appended.append(AutomationAppended(to=to, bytes=size, text=text[:PREVIEW]))
|
|
1037
|
+
continue
|
|
1038
|
+
where = f"run {record.id}" + (
|
|
1039
|
+
f" · session {record.session_id}" if record.session_id else ""
|
|
1040
|
+
)
|
|
1041
|
+
t = now().isoformat(timespec="seconds")
|
|
1042
|
+
head = f"<!-- handler automation {loaded.name} · {where} · {t} -->"
|
|
1043
|
+
block = f"\n{head}\n{text}" + ("" if text.endswith("\n") else "\n")
|
|
1044
|
+
knowledge.append_note(root, to, block)
|
|
1045
|
+
appended.append(AutomationAppended(to=to, bytes=len(block.encode())))
|
|
1046
|
+
return appended
|
|
1047
|
+
|
|
1048
|
+
def end(
|
|
1049
|
+
self,
|
|
1050
|
+
record: AutomationRun,
|
|
1051
|
+
state: str,
|
|
1052
|
+
*,
|
|
1053
|
+
error: str | None = None,
|
|
1054
|
+
appended: list[AutomationAppended] | None = None,
|
|
1055
|
+
result: dict[str, Any] | None = None,
|
|
1056
|
+
move: bool = True,
|
|
1057
|
+
) -> None:
|
|
1058
|
+
"""Its record, then (a run of a stop) its cursor: the part it read is read (I.2); then
|
|
1059
|
+
what the inbox is told (I.3): an agent run's result, a failure."""
|
|
1060
|
+
update: dict[str, Any] = {"state": state, "ended_at": now(), "error": error}
|
|
1061
|
+
if appended is not None:
|
|
1062
|
+
update["appended"] = appended
|
|
1063
|
+
record = record.model_copy(update=update)
|
|
1064
|
+
self.write_record(record)
|
|
1065
|
+
if move and not record.trial and record.session_id is not None:
|
|
1066
|
+
session_id, seq_to = record.session_id, record.seq_to or 0
|
|
1067
|
+
|
|
1068
|
+
def moved(s: config.State) -> None:
|
|
1069
|
+
s.cursors[session_id] = max(s.cursors.get(session_id, 0), seq_to)
|
|
1070
|
+
|
|
1071
|
+
self.update_state(record.automation, moved)
|
|
1072
|
+
self.tell(record, result)
|
|
1073
|
+
self.prune(record.automation)
|
|
1074
|
+
|
|
1075
|
+
def tell(self, record: AutomationRun, result: dict[str, Any] | None) -> None:
|
|
1076
|
+
"""The inbox message of a run that ended (I.3): an agent run's result (a try's too), a
|
|
1077
|
+
failure (not a try's: its page shows it), nothing else."""
|
|
1078
|
+
loaded = config.get(self.settings.automations_dir, record.automation)
|
|
1079
|
+
run = loaded.definition.agent_run if loaded and loaded.definition else None
|
|
1080
|
+
title = record.automation + (" (try)" if record.trial else "")
|
|
1081
|
+
fields: dict[str, Any] = {
|
|
1082
|
+
"source": "automation",
|
|
1083
|
+
"name": record.automation,
|
|
1084
|
+
"agent": run.agent if run else None,
|
|
1085
|
+
"person": "owner",
|
|
1086
|
+
"title": title,
|
|
1087
|
+
"run_id": record.id,
|
|
1088
|
+
"session_id": record.agent_session_id,
|
|
1089
|
+
}
|
|
1090
|
+
if result is not None:
|
|
1091
|
+
status: InboxStatus = result["status"] if result.get("status") in STATUSES else "ok"
|
|
1092
|
+
summary = result.get("summary")
|
|
1093
|
+
summary = summary if isinstance(summary, str) else None
|
|
1094
|
+
self.inbox.add(**fields, status=status, summary=summary, data=result)
|
|
1095
|
+
elif record.state == "failed" and (run is not None or not record.trial):
|
|
1096
|
+
self.inbox.add(**fields, status="failed", summary=record.error)
|
|
1097
|
+
|
|
1098
|
+
def prune(self, name: str) -> None:
|
|
1099
|
+
"""The last KEEP_RUNS runs, none older than KEEP_DAYS days."""
|
|
1100
|
+
oldest = now() - timedelta(days=KEEP_DAYS)
|
|
1101
|
+
busy = self.running.record.id if self.running else None
|
|
1102
|
+
for i, record in enumerate(self.runs(name)):
|
|
1103
|
+
if (
|
|
1104
|
+
record.id != busy
|
|
1105
|
+
and record.state != "queued"
|
|
1106
|
+
and (i >= KEEP_RUNS or record.created_at < oldest)
|
|
1107
|
+
):
|
|
1108
|
+
shutil.rmtree(self.runs_dir(name) / record.id, ignore_errors=True)
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def new_run_id() -> str:
|
|
1112
|
+
return "a_" + pysecrets.token_hex(8)
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
async def copy_log(proc: Any, path: Path, redact: Callable[[str], str]) -> None:
|
|
1116
|
+
"""The run's output into run.log, redacted, at most MAX_LOG bytes (the rest is read, not
|
|
1117
|
+
kept)."""
|
|
1118
|
+
written = 0
|
|
1119
|
+
with path.open("w") as f:
|
|
1120
|
+
while True:
|
|
1121
|
+
try:
|
|
1122
|
+
raw = await proc.stdout.readline()
|
|
1123
|
+
except (ValueError, asyncio.LimitOverrunError):
|
|
1124
|
+
raw = b"(a line longer than 1 MiB, left out)\n"
|
|
1125
|
+
if not raw:
|
|
1126
|
+
break
|
|
1127
|
+
line = redact(raw.decode(errors="replace"))
|
|
1128
|
+
if written < MAX_LOG:
|
|
1129
|
+
kept = line[: MAX_LOG - written]
|
|
1130
|
+
f.write(kept)
|
|
1131
|
+
written += len(kept)
|
|
1132
|
+
if written >= MAX_LOG:
|
|
1133
|
+
f.write("\n… the log is cut at 1 MiB\n")
|
|
1134
|
+
f.flush()
|