delimit-cli 4.1.53 → 4.3.0
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.
- package/CHANGELOG.md +26 -0
- package/README.md +34 -3
- package/bin/delimit-cli.js +150 -2
- package/bin/delimit-setup.js +22 -7
- package/gateway/ai/agent_dispatch.py +79 -0
- package/gateway/ai/daily_digest.py +386 -0
- package/gateway/ai/ledger_manager.py +32 -0
- package/gateway/ai/license_core.py +2 -0
- package/gateway/ai/notify.py +17 -11
- package/gateway/ai/reddit_proxy.py +28 -9
- package/gateway/ai/sensing/__init__.py +35 -0
- package/gateway/ai/sensing/schema.py +107 -0
- package/gateway/ai/sensing/signal_store.py +348 -0
- package/gateway/ai/server.py +419 -6
- package/gateway/ai/supabase_sync.py +308 -0
- package/gateway/ai/work_order.py +216 -0
- package/gateway/ai/workers/__init__.py +32 -0
- package/gateway/ai/workers/base.py +154 -0
- package/gateway/ai/workers/executor.py +861 -0
- package/gateway/ai/workers/outreach_drafter.py +161 -0
- package/gateway/ai/workers/pr_drafter.py +148 -0
- package/lib/ai-sbom-engine.js +154 -0
- package/lib/trust-page-engine.js +179 -0
- package/lib/wrap-engine.js +431 -0
- package/package.json +14 -1
- package/adapters/codex-security.js +0 -64
- package/adapters/codex-skill.js +0 -78
- package/adapters/cursor-rules.js +0 -73
- package/gateway/ai/continuity.py +0 -462
- package/gateway/ai/inbox_daemon_runner.py +0 -217
- package/gateway/ai/loop_engine.py +0 -1303
- package/gateway/ai/social_cache.py +0 -341
- package/gateway/ai/social_daemon.py +0 -483
- package/gateway/ai/tweet_corpus_schema.sql +0 -76
- package/scripts/crosspost_devto.py +0 -304
- package/scripts/demo-v420-clean.sh +0 -267
- package/scripts/demo-v420-deliberation.sh +0 -217
- package/scripts/demo-v420.sh +0 -55
- package/scripts/sync-gateway.sh +0 -112
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
"""LED-981: Worker Pool v2 executor.
|
|
2
|
+
|
|
3
|
+
Takes an approved work order and runs its `executable_actions` list
|
|
4
|
+
against a narrow whitelist of state-changing operations. This is where
|
|
5
|
+
the founder's "dashboard approve → autonomous execute" loop closes.
|
|
6
|
+
|
|
7
|
+
Safety model (defense in depth):
|
|
8
|
+
|
|
9
|
+
1. Workers only emit actions from a hardcoded whitelist (ACTION_SPEC).
|
|
10
|
+
Any unknown action is rejected at draft time by validate_actions().
|
|
11
|
+
2. The executor re-validates before running. A work order that was
|
|
12
|
+
approved by a human can never execute an action the executor can't
|
|
13
|
+
type-check.
|
|
14
|
+
3. Each action has a fixed parameter shape; missing or extra params
|
|
15
|
+
are rejected.
|
|
16
|
+
4. Execution never shells out to an arbitrary command. Every action
|
|
17
|
+
has a Python implementation that calls a specific subprocess or
|
|
18
|
+
library with bounded inputs.
|
|
19
|
+
5. Every action (success or failure) is appended to execution_log and
|
|
20
|
+
synced to Supabase so the dashboard shows what the executor did.
|
|
21
|
+
6. Dry-run mode is the DEFAULT. Live execution requires
|
|
22
|
+
`execute_approved(..., live=True)` — the MCP tool wrapper defaults
|
|
23
|
+
to live=False so an accidental call is observable without effect.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import shlex
|
|
30
|
+
import subprocess
|
|
31
|
+
import time
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any, Dict, List, Optional
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger("delimit.workers.executor")
|
|
37
|
+
|
|
38
|
+
WORK_ORDERS_DIR = Path.home() / ".delimit" / "work-orders"
|
|
39
|
+
EXECUTOR_AUDIT = Path.home() / ".delimit" / "workers" / "audit" / "executor.jsonl"
|
|
40
|
+
# Kill switch. Matches the charter's kill-switch table — touch this file
|
|
41
|
+
# and the poller stops flipping approved work orders into live execution.
|
|
42
|
+
EXECUTOR_PAUSE_FILE = Path.home() / ".delimit" / "pause_executor"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Action specification — THE whitelist. Add an action type by editing here.
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
ACTION_SPEC: Dict[str, Dict[str, Any]] = {
|
|
50
|
+
"gh_issue_create": {
|
|
51
|
+
"required_params": ("repo", "title", "body"),
|
|
52
|
+
"optional_params": ("labels",),
|
|
53
|
+
"description": "Open a GitHub issue on an external repo via gh CLI.",
|
|
54
|
+
},
|
|
55
|
+
"gh_pr_comment": {
|
|
56
|
+
"required_params": ("repo", "number", "body"),
|
|
57
|
+
"optional_params": (),
|
|
58
|
+
"description": "Add a comment to an open GitHub PR.",
|
|
59
|
+
},
|
|
60
|
+
"gh_issue_comment": {
|
|
61
|
+
"required_params": ("repo", "number", "body"),
|
|
62
|
+
"optional_params": (),
|
|
63
|
+
"description": "Add a comment to an open GitHub issue.",
|
|
64
|
+
},
|
|
65
|
+
"gh_issue_close": {
|
|
66
|
+
"required_params": ("repo", "number"),
|
|
67
|
+
"optional_params": ("comment", "reason"),
|
|
68
|
+
"description": "Close a GitHub issue, optionally with a closing comment and reason.",
|
|
69
|
+
},
|
|
70
|
+
"gh_issue_reopen": {
|
|
71
|
+
"required_params": ("repo", "number"),
|
|
72
|
+
"optional_params": ("comment",),
|
|
73
|
+
"description": "Reopen a closed GitHub issue, optionally with an explanatory comment.",
|
|
74
|
+
},
|
|
75
|
+
"gh_issue_label": {
|
|
76
|
+
"required_params": ("repo", "number", "labels"),
|
|
77
|
+
"optional_params": ("remove",),
|
|
78
|
+
"description": "Add labels (or remove when remove=true) from a GitHub issue or PR.",
|
|
79
|
+
},
|
|
80
|
+
"gh_pr_ready_for_review": {
|
|
81
|
+
"required_params": ("repo", "number"),
|
|
82
|
+
"optional_params": (),
|
|
83
|
+
"description": "Mark a draft PR as ready for review.",
|
|
84
|
+
},
|
|
85
|
+
"propose_pr": {
|
|
86
|
+
"required_params": ("repo_path", "branch", "title", "body", "files"),
|
|
87
|
+
"optional_params": ("tests_cmd", "base_branch", "draft", "commit_message"),
|
|
88
|
+
"description": (
|
|
89
|
+
"LED-988 autonomous build primitive: branch → write files → test → "
|
|
90
|
+
"commit → push → open draft PR. Stops at PR opened — merge and "
|
|
91
|
+
"tag-push stay human per 2026-04-07 postmortem."
|
|
92
|
+
),
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# LED-988: allowlist for propose_pr. Any repo path NOT in this set is
|
|
98
|
+
# rejected at runtime regardless of whether the caller claimed validation
|
|
99
|
+
# passed. Path-traversal-safe (resolved then checked against canonical).
|
|
100
|
+
PROPOSE_PR_ALLOWED_REPOS = frozenset({
|
|
101
|
+
"/home/delimit/delimit-gateway",
|
|
102
|
+
"/home/delimit/delimit-ui",
|
|
103
|
+
"/home/delimit/delimit-action",
|
|
104
|
+
"/home/delimit/npm-delimit",
|
|
105
|
+
"/root/governance-framework",
|
|
106
|
+
})
|
|
107
|
+
# Any branch created by propose_pr must carry this prefix so human branches
|
|
108
|
+
# are never clobbered and PRs are obviously agent-authored at a glance.
|
|
109
|
+
PROPOSE_PR_BRANCH_PREFIX = "delimit/"
|
|
110
|
+
# Commit author for autonomous commits. Bot-pattern email so GitHub
|
|
111
|
+
# counts contributions correctly without attributing to a human.
|
|
112
|
+
PROPOSE_PR_AUTHOR_NAME = "delimit-bot"
|
|
113
|
+
PROPOSE_PR_AUTHOR_EMAIL = "bot@delimit.ai"
|
|
114
|
+
# Hard cap on patch size — rejects accidental mega-diffs that would
|
|
115
|
+
# require a different review workflow anyway.
|
|
116
|
+
PROPOSE_PR_MAX_FILES = 50
|
|
117
|
+
PROPOSE_PR_MAX_FILE_BYTES = 256 * 1024 # 256 KiB / file
|
|
118
|
+
PROPOSE_PR_MAX_TOTAL_BYTES = 1024 * 1024 # 1 MiB / PR
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ActionError(Exception):
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# LED-988 (Polymarket/RunLobster deliberation): explicit category denylist.
|
|
126
|
+
# The executor's guardrail today is implicit ("only whitelisted actions run")
|
|
127
|
+
# which is necessary but not sufficient — a future whitelist extension could
|
|
128
|
+
# silently add a category that belongs behind a charter amendment. This
|
|
129
|
+
# denylist is a hard second gate. An action type OR any parameter value
|
|
130
|
+
# matching any token here is rejected at validate_actions() time with a
|
|
131
|
+
# loud error, regardless of whether it's in ACTION_SPEC.
|
|
132
|
+
#
|
|
133
|
+
# Match is substring on the lowercased action name, param key, and param
|
|
134
|
+
# string-value. Add a category here by editing this set; removing a
|
|
135
|
+
# category requires a charter amendment + deliberation (commit message
|
|
136
|
+
# must cite the amendment).
|
|
137
|
+
ACTION_DENYLIST_TOKENS = frozenset({
|
|
138
|
+
# Money / payments
|
|
139
|
+
"financial_transaction",
|
|
140
|
+
"payment_api",
|
|
141
|
+
"stripe_charge",
|
|
142
|
+
"stripe_transfer",
|
|
143
|
+
"wire_transfer",
|
|
144
|
+
"ach_transfer",
|
|
145
|
+
"lemonsqueezy_charge",
|
|
146
|
+
"plaid_link",
|
|
147
|
+
# Legal / identity
|
|
148
|
+
"llc_registration",
|
|
149
|
+
"ein_application",
|
|
150
|
+
"company_formation",
|
|
151
|
+
"identity_registration",
|
|
152
|
+
"kyc_submit",
|
|
153
|
+
"aml_submit",
|
|
154
|
+
# Credentials / auth handling
|
|
155
|
+
"private_key_export",
|
|
156
|
+
"private_key_generate",
|
|
157
|
+
"seed_phrase",
|
|
158
|
+
"api_key_rotate_external", # rotating user-owned keys not in our vault
|
|
159
|
+
# Autonomous deploy to prod outside our repos
|
|
160
|
+
"external_deploy",
|
|
161
|
+
"terraform_apply",
|
|
162
|
+
"kubectl_apply",
|
|
163
|
+
# Contract signing / binding legal action
|
|
164
|
+
"contract_sign",
|
|
165
|
+
"docusign_send",
|
|
166
|
+
"hello_sign",
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _denylist_hits(name: str, params: Dict[str, Any]) -> List[str]:
|
|
171
|
+
"""Return every denylist token found in the action name or param values."""
|
|
172
|
+
hits: List[str] = []
|
|
173
|
+
haystack = [(name or "").lower()]
|
|
174
|
+
for k, v in (params or {}).items():
|
|
175
|
+
haystack.append(str(k).lower())
|
|
176
|
+
if isinstance(v, str):
|
|
177
|
+
haystack.append(v.lower())
|
|
178
|
+
blob = " ".join(haystack)
|
|
179
|
+
for token in ACTION_DENYLIST_TOKENS:
|
|
180
|
+
if token in blob:
|
|
181
|
+
hits.append(token)
|
|
182
|
+
return hits
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
# Validation
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def validate_actions(actions: List[Dict[str, Any]]) -> List[str]:
|
|
190
|
+
"""Return a list of error strings — empty list means actions are valid."""
|
|
191
|
+
errors: List[str] = []
|
|
192
|
+
if not isinstance(actions, list):
|
|
193
|
+
return [f"executable_actions must be a list, got {type(actions).__name__}"]
|
|
194
|
+
for i, action in enumerate(actions):
|
|
195
|
+
if not isinstance(action, dict):
|
|
196
|
+
errors.append(f"action[{i}]: must be a dict")
|
|
197
|
+
continue
|
|
198
|
+
action_type = action.get("action")
|
|
199
|
+
# Denylist check BEFORE whitelist check. A hit here fails loud even
|
|
200
|
+
# if the action happens to be in ACTION_SPEC (belt + suspenders —
|
|
201
|
+
# accidental whitelist addition can't slip a denied category through).
|
|
202
|
+
params = action.get("params") or {}
|
|
203
|
+
if isinstance(params, dict):
|
|
204
|
+
deny_hits = _denylist_hits(action_type or "", params)
|
|
205
|
+
if deny_hits:
|
|
206
|
+
errors.append(
|
|
207
|
+
f"action[{i}]: DENYLIST HIT for {action_type!r} — tokens "
|
|
208
|
+
f"{sorted(deny_hits)} are prohibited categories per "
|
|
209
|
+
f"LED-988 / Polymarket deliberation. Removing one of "
|
|
210
|
+
f"these requires a charter amendment."
|
|
211
|
+
)
|
|
212
|
+
continue
|
|
213
|
+
if action_type not in ACTION_SPEC:
|
|
214
|
+
errors.append(
|
|
215
|
+
f"action[{i}]: unknown action '{action_type}'. "
|
|
216
|
+
f"Allowed: {sorted(ACTION_SPEC.keys())}"
|
|
217
|
+
)
|
|
218
|
+
continue
|
|
219
|
+
spec = ACTION_SPEC[action_type]
|
|
220
|
+
params = action.get("params") or {}
|
|
221
|
+
if not isinstance(params, dict):
|
|
222
|
+
errors.append(f"action[{i}]: params must be a dict")
|
|
223
|
+
continue
|
|
224
|
+
for required in spec["required_params"]:
|
|
225
|
+
if required not in params:
|
|
226
|
+
errors.append(
|
|
227
|
+
f"action[{i}] ({action_type}): missing required param '{required}'"
|
|
228
|
+
)
|
|
229
|
+
allowed = set(spec["required_params"]) | set(spec["optional_params"])
|
|
230
|
+
for provided in params:
|
|
231
|
+
if provided not in allowed:
|
|
232
|
+
errors.append(
|
|
233
|
+
f"action[{i}] ({action_type}): unknown param '{provided}'. "
|
|
234
|
+
f"Allowed: {sorted(allowed)}"
|
|
235
|
+
)
|
|
236
|
+
return errors
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
# Action implementations
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def _run_gh(args: List[str], stdin: Optional[str] = None, timeout: int = 60) -> Dict[str, Any]:
|
|
244
|
+
"""Run a gh subcommand with bounded inputs. Returns dict with stdout/stderr/rc."""
|
|
245
|
+
cmd = ["gh", *args]
|
|
246
|
+
logger.info("executor: running %s", " ".join(shlex.quote(a) for a in cmd))
|
|
247
|
+
try:
|
|
248
|
+
result = subprocess.run(
|
|
249
|
+
cmd,
|
|
250
|
+
input=stdin,
|
|
251
|
+
capture_output=True,
|
|
252
|
+
text=True,
|
|
253
|
+
timeout=timeout,
|
|
254
|
+
)
|
|
255
|
+
return {
|
|
256
|
+
"rc": result.returncode,
|
|
257
|
+
"stdout": (result.stdout or "")[:4000],
|
|
258
|
+
"stderr": (result.stderr or "")[:2000],
|
|
259
|
+
}
|
|
260
|
+
except subprocess.TimeoutExpired:
|
|
261
|
+
raise ActionError(f"gh timed out after {timeout}s")
|
|
262
|
+
except FileNotFoundError:
|
|
263
|
+
raise ActionError("gh CLI not installed")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _act_gh_issue_create(params: Dict[str, Any]) -> Dict[str, Any]:
|
|
267
|
+
repo = params["repo"]
|
|
268
|
+
title = params["title"]
|
|
269
|
+
body = params["body"]
|
|
270
|
+
labels = params.get("labels") or []
|
|
271
|
+
args = ["issue", "create", "--repo", repo, "--title", title, "--body-file", "-"]
|
|
272
|
+
for label in labels:
|
|
273
|
+
args.extend(["--label", label])
|
|
274
|
+
result = _run_gh(args, stdin=body)
|
|
275
|
+
if result["rc"] != 0:
|
|
276
|
+
raise ActionError(f"gh issue create failed: {result['stderr']}")
|
|
277
|
+
return {"issue_url": result["stdout"].strip()}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _act_gh_pr_comment(params: Dict[str, Any]) -> Dict[str, Any]:
|
|
281
|
+
repo = params["repo"]
|
|
282
|
+
number = str(params["number"])
|
|
283
|
+
body = params["body"]
|
|
284
|
+
result = _run_gh(
|
|
285
|
+
["pr", "comment", number, "--repo", repo, "--body-file", "-"],
|
|
286
|
+
stdin=body,
|
|
287
|
+
)
|
|
288
|
+
if result["rc"] != 0:
|
|
289
|
+
raise ActionError(f"gh pr comment failed: {result['stderr']}")
|
|
290
|
+
return {"comment_url": result["stdout"].strip()}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _act_gh_issue_comment(params: Dict[str, Any]) -> Dict[str, Any]:
|
|
294
|
+
repo = params["repo"]
|
|
295
|
+
number = str(params["number"])
|
|
296
|
+
body = params["body"]
|
|
297
|
+
result = _run_gh(
|
|
298
|
+
["issue", "comment", number, "--repo", repo, "--body-file", "-"],
|
|
299
|
+
stdin=body,
|
|
300
|
+
)
|
|
301
|
+
if result["rc"] != 0:
|
|
302
|
+
raise ActionError(f"gh issue comment failed: {result['stderr']}")
|
|
303
|
+
return {"comment_url": result["stdout"].strip()}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _act_gh_issue_close(params: Dict[str, Any]) -> Dict[str, Any]:
|
|
307
|
+
repo = params["repo"]
|
|
308
|
+
number = str(params["number"])
|
|
309
|
+
comment = params.get("comment")
|
|
310
|
+
# gh close --reason accepts: completed | not planned
|
|
311
|
+
reason = params.get("reason")
|
|
312
|
+
args = ["issue", "close", number, "--repo", repo]
|
|
313
|
+
if comment:
|
|
314
|
+
args.extend(["--comment", comment])
|
|
315
|
+
if reason:
|
|
316
|
+
if reason not in ("completed", "not planned"):
|
|
317
|
+
raise ActionError(f"reason must be 'completed' or 'not planned', got {reason!r}")
|
|
318
|
+
args.extend(["--reason", reason])
|
|
319
|
+
result = _run_gh(args)
|
|
320
|
+
if result["rc"] != 0:
|
|
321
|
+
raise ActionError(f"gh issue close failed: {result['stderr']}")
|
|
322
|
+
return {"closed": f"{repo}#{number}", "stdout": result["stdout"].strip()}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _act_gh_issue_reopen(params: Dict[str, Any]) -> Dict[str, Any]:
|
|
326
|
+
repo = params["repo"]
|
|
327
|
+
number = str(params["number"])
|
|
328
|
+
comment = params.get("comment")
|
|
329
|
+
args = ["issue", "reopen", number, "--repo", repo]
|
|
330
|
+
if comment:
|
|
331
|
+
args.extend(["--comment", comment])
|
|
332
|
+
result = _run_gh(args)
|
|
333
|
+
if result["rc"] != 0:
|
|
334
|
+
raise ActionError(f"gh issue reopen failed: {result['stderr']}")
|
|
335
|
+
return {"reopened": f"{repo}#{number}", "stdout": result["stdout"].strip()}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _act_gh_issue_label(params: Dict[str, Any]) -> Dict[str, Any]:
|
|
339
|
+
repo = params["repo"]
|
|
340
|
+
number = str(params["number"])
|
|
341
|
+
labels = params["labels"]
|
|
342
|
+
remove = bool(params.get("remove", False))
|
|
343
|
+
if not isinstance(labels, list) or not labels:
|
|
344
|
+
raise ActionError("labels must be a non-empty list")
|
|
345
|
+
# `gh issue edit` covers both add and remove and works for PRs too
|
|
346
|
+
flag = "--remove-label" if remove else "--add-label"
|
|
347
|
+
args = ["issue", "edit", number, "--repo", repo]
|
|
348
|
+
for label in labels:
|
|
349
|
+
if not isinstance(label, str) or not label:
|
|
350
|
+
raise ActionError(f"every label must be a non-empty string, got {label!r}")
|
|
351
|
+
args.extend([flag, label])
|
|
352
|
+
result = _run_gh(args)
|
|
353
|
+
if result["rc"] != 0:
|
|
354
|
+
raise ActionError(f"gh issue edit ({flag}) failed: {result['stderr']}")
|
|
355
|
+
return {"labeled": f"{repo}#{number}", "action": "remove" if remove else "add", "labels": labels}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _act_gh_pr_ready_for_review(params: Dict[str, Any]) -> Dict[str, Any]:
|
|
359
|
+
repo = params["repo"]
|
|
360
|
+
number = str(params["number"])
|
|
361
|
+
# `gh pr ready` flips a draft PR to ready-for-review state
|
|
362
|
+
result = _run_gh(["pr", "ready", number, "--repo", repo])
|
|
363
|
+
if result["rc"] != 0:
|
|
364
|
+
raise ActionError(f"gh pr ready failed: {result['stderr']}")
|
|
365
|
+
return {"ready": f"{repo}#{number}", "stdout": result["stdout"].strip()}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _run_git(cwd: str, args: List[str], timeout: int = 60) -> Dict[str, Any]:
|
|
369
|
+
"""Run git in `cwd`. Returns {rc, stdout, stderr} — no raising here so
|
|
370
|
+
the caller decides which non-zero returns are fatal vs recoverable."""
|
|
371
|
+
cmd = ["git", "-C", cwd, *args]
|
|
372
|
+
try:
|
|
373
|
+
result = subprocess.run(
|
|
374
|
+
cmd, capture_output=True, text=True, timeout=timeout,
|
|
375
|
+
)
|
|
376
|
+
return {
|
|
377
|
+
"rc": result.returncode,
|
|
378
|
+
"stdout": (result.stdout or "")[:4000],
|
|
379
|
+
"stderr": (result.stderr or "")[:2000],
|
|
380
|
+
}
|
|
381
|
+
except subprocess.TimeoutExpired:
|
|
382
|
+
raise ActionError(f"git timed out after {timeout}s: {' '.join(args[:3])}")
|
|
383
|
+
except FileNotFoundError:
|
|
384
|
+
raise ActionError("git CLI not installed")
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _act_propose_pr(params: Dict[str, Any]) -> Dict[str, Any]:
|
|
388
|
+
"""LED-988 autonomous build primitive.
|
|
389
|
+
|
|
390
|
+
Flow: (resolve + allowlist) → checkout base + pull → create branch →
|
|
391
|
+
write files → run tests if provided → commit with bot identity →
|
|
392
|
+
push → open draft PR → return PR URL. Stops there.
|
|
393
|
+
|
|
394
|
+
Safety invariants enforced at runtime, not just at validation time:
|
|
395
|
+
- repo_path must resolve to a path in PROPOSE_PR_ALLOWED_REPOS
|
|
396
|
+
- branch must carry PROPOSE_PR_BRANCH_PREFIX (never clobber human work)
|
|
397
|
+
- file paths must be relative, no `..`, no absolute, no symlink hops
|
|
398
|
+
- total patch size capped at 1 MiB / 50 files
|
|
399
|
+
- tests_cmd failure aborts before push — no broken PR ever opens
|
|
400
|
+
- PR opens as draft by default; gh_pr_ready_for_review is a separate
|
|
401
|
+
whitelisted action the founder can invoke after review
|
|
402
|
+
- bot identity is set via `git -c` (per-command) — never mutates
|
|
403
|
+
repo or global git config
|
|
404
|
+
"""
|
|
405
|
+
from pathlib import Path as _Path
|
|
406
|
+
|
|
407
|
+
repo_path_raw = params["repo_path"]
|
|
408
|
+
branch = params["branch"]
|
|
409
|
+
title = params["title"]
|
|
410
|
+
body = params["body"]
|
|
411
|
+
files = params["files"]
|
|
412
|
+
tests_cmd = params.get("tests_cmd") or ""
|
|
413
|
+
base_branch = params.get("base_branch") or "main"
|
|
414
|
+
draft = params.get("draft", True)
|
|
415
|
+
commit_message = params.get("commit_message") or title
|
|
416
|
+
|
|
417
|
+
# 1. Allowlist the repo path (canonical, resolves symlinks)
|
|
418
|
+
try:
|
|
419
|
+
repo_path = str(_Path(repo_path_raw).resolve(strict=True))
|
|
420
|
+
except (FileNotFoundError, RuntimeError) as exc:
|
|
421
|
+
raise ActionError(f"repo_path not found: {repo_path_raw} ({exc})")
|
|
422
|
+
if repo_path not in PROPOSE_PR_ALLOWED_REPOS:
|
|
423
|
+
raise ActionError(
|
|
424
|
+
f"repo_path not in allowlist: {repo_path}. "
|
|
425
|
+
f"Allowed: {sorted(PROPOSE_PR_ALLOWED_REPOS)}"
|
|
426
|
+
)
|
|
427
|
+
if not (_Path(repo_path) / ".git").exists():
|
|
428
|
+
raise ActionError(f"repo_path is not a git repo: {repo_path}")
|
|
429
|
+
|
|
430
|
+
# 2. Branch prefix guard
|
|
431
|
+
if not isinstance(branch, str) or not branch.startswith(PROPOSE_PR_BRANCH_PREFIX):
|
|
432
|
+
raise ActionError(
|
|
433
|
+
f"branch must start with {PROPOSE_PR_BRANCH_PREFIX!r}, got {branch!r}"
|
|
434
|
+
)
|
|
435
|
+
if "/" not in branch[len(PROPOSE_PR_BRANCH_PREFIX):] and not branch[len(PROPOSE_PR_BRANCH_PREFIX):]:
|
|
436
|
+
raise ActionError("branch is empty after prefix")
|
|
437
|
+
|
|
438
|
+
# 3. File-list validation: size cap, no absolute paths, no `..`, required
|
|
439
|
+
# content for every entry.
|
|
440
|
+
if not isinstance(files, list) or not files:
|
|
441
|
+
raise ActionError("files must be a non-empty list")
|
|
442
|
+
if len(files) > PROPOSE_PR_MAX_FILES:
|
|
443
|
+
raise ActionError(f"files > {PROPOSE_PR_MAX_FILES} (got {len(files)})")
|
|
444
|
+
total = 0
|
|
445
|
+
for entry in files:
|
|
446
|
+
if not isinstance(entry, dict):
|
|
447
|
+
raise ActionError("each file entry must be a dict")
|
|
448
|
+
p = entry.get("path")
|
|
449
|
+
c = entry.get("content", "")
|
|
450
|
+
if not isinstance(p, str) or not p:
|
|
451
|
+
raise ActionError("file.path required + must be a string")
|
|
452
|
+
if p.startswith("/") or ".." in _Path(p).parts or p.startswith("~"):
|
|
453
|
+
raise ActionError(f"file.path must be relative + inside repo: {p!r}")
|
|
454
|
+
if not isinstance(c, str):
|
|
455
|
+
raise ActionError(f"file.content must be a string for {p!r}")
|
|
456
|
+
if len(c.encode("utf-8")) > PROPOSE_PR_MAX_FILE_BYTES:
|
|
457
|
+
raise ActionError(
|
|
458
|
+
f"{p!r} exceeds {PROPOSE_PR_MAX_FILE_BYTES} bytes"
|
|
459
|
+
)
|
|
460
|
+
total += len(c.encode("utf-8"))
|
|
461
|
+
if total > PROPOSE_PR_MAX_TOTAL_BYTES:
|
|
462
|
+
raise ActionError(
|
|
463
|
+
f"total patch {total}B exceeds {PROPOSE_PR_MAX_TOTAL_BYTES}B"
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
# 4. Confirm working tree clean + base branch exists + fetch
|
|
467
|
+
status = _run_git(repo_path, ["status", "--porcelain"])
|
|
468
|
+
if status["rc"] != 0:
|
|
469
|
+
raise ActionError(f"git status failed: {status['stderr']}")
|
|
470
|
+
if status["stdout"].strip():
|
|
471
|
+
raise ActionError(
|
|
472
|
+
f"repo working tree dirty — refusing to propose on top of uncommitted "
|
|
473
|
+
f"work:\n{status['stdout'][:500]}"
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
if _run_git(repo_path, ["fetch", "origin", base_branch])["rc"] != 0:
|
|
477
|
+
raise ActionError(f"could not fetch origin/{base_branch}")
|
|
478
|
+
checkout_base = _run_git(repo_path, ["checkout", base_branch])
|
|
479
|
+
if checkout_base["rc"] != 0:
|
|
480
|
+
raise ActionError(f"checkout {base_branch} failed: {checkout_base['stderr']}")
|
|
481
|
+
pull = _run_git(repo_path, ["pull", "--ff-only", "origin", base_branch])
|
|
482
|
+
if pull["rc"] != 0:
|
|
483
|
+
raise ActionError(f"pull --ff-only origin/{base_branch} failed: {pull['stderr']}")
|
|
484
|
+
|
|
485
|
+
# 5. Create the branch
|
|
486
|
+
if _run_git(repo_path, ["checkout", "-b", branch])["rc"] != 0:
|
|
487
|
+
# Maybe it already exists; switch + reset to base
|
|
488
|
+
if _run_git(repo_path, ["checkout", branch])["rc"] != 0:
|
|
489
|
+
raise ActionError(f"could not create or switch to {branch}")
|
|
490
|
+
reset = _run_git(repo_path, ["reset", "--hard", f"origin/{base_branch}"])
|
|
491
|
+
if reset["rc"] != 0:
|
|
492
|
+
raise ActionError(f"could not reset {branch} to base: {reset['stderr']}")
|
|
493
|
+
|
|
494
|
+
# 6. Write the files (create dirs as needed)
|
|
495
|
+
written: List[str] = []
|
|
496
|
+
try:
|
|
497
|
+
for entry in files:
|
|
498
|
+
dest = _Path(repo_path) / entry["path"]
|
|
499
|
+
dest.resolve().relative_to(repo_path) # defense in depth
|
|
500
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
501
|
+
dest.write_text(entry["content"])
|
|
502
|
+
written.append(entry["path"])
|
|
503
|
+
except ValueError as exc:
|
|
504
|
+
raise ActionError(f"file path escaped repo: {exc}")
|
|
505
|
+
|
|
506
|
+
# 7. Stage + optional tests BEFORE commit
|
|
507
|
+
stage = _run_git(repo_path, ["add", *written])
|
|
508
|
+
if stage["rc"] != 0:
|
|
509
|
+
raise ActionError(f"git add failed: {stage['stderr']}")
|
|
510
|
+
|
|
511
|
+
if tests_cmd:
|
|
512
|
+
logger.info("propose_pr: running tests: %s", tests_cmd)
|
|
513
|
+
try:
|
|
514
|
+
tests_proc = subprocess.run(
|
|
515
|
+
tests_cmd, shell=True, cwd=repo_path,
|
|
516
|
+
capture_output=True, text=True, timeout=600,
|
|
517
|
+
)
|
|
518
|
+
except subprocess.TimeoutExpired:
|
|
519
|
+
raise ActionError("tests_cmd timed out after 600s")
|
|
520
|
+
if tests_proc.returncode != 0:
|
|
521
|
+
# Clean up so the working tree isn't left dirty on the branch.
|
|
522
|
+
_run_git(repo_path, ["reset", "--hard", f"origin/{base_branch}"])
|
|
523
|
+
_run_git(repo_path, ["checkout", base_branch])
|
|
524
|
+
_run_git(repo_path, ["branch", "-D", branch])
|
|
525
|
+
raise ActionError(
|
|
526
|
+
f"tests failed (rc={tests_proc.returncode}); branch {branch} "
|
|
527
|
+
f"discarded.\nstdout tail:\n{tests_proc.stdout[-2000:]}\n"
|
|
528
|
+
f"stderr tail:\n{tests_proc.stderr[-2000:]}"
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
# 8. Commit with the bot identity (per-command -c, never global)
|
|
532
|
+
commit_args = [
|
|
533
|
+
"-c", f"user.name={PROPOSE_PR_AUTHOR_NAME}",
|
|
534
|
+
"-c", f"user.email={PROPOSE_PR_AUTHOR_EMAIL}",
|
|
535
|
+
"commit",
|
|
536
|
+
"-m", commit_message,
|
|
537
|
+
]
|
|
538
|
+
commit = _run_git(repo_path, commit_args)
|
|
539
|
+
if commit["rc"] != 0:
|
|
540
|
+
raise ActionError(f"commit failed: {commit['stderr']}")
|
|
541
|
+
|
|
542
|
+
# 9. Push (no --force, no --force-with-lease — branch is fresh)
|
|
543
|
+
push = _run_git(repo_path, ["push", "-u", "origin", branch])
|
|
544
|
+
if push["rc"] != 0:
|
|
545
|
+
raise ActionError(f"push origin {branch} failed: {push['stderr']}")
|
|
546
|
+
|
|
547
|
+
# 10. Open the PR via gh (draft by default — human flips it with
|
|
548
|
+
# gh_pr_ready_for_review after review)
|
|
549
|
+
gh_args = [
|
|
550
|
+
"pr", "create",
|
|
551
|
+
"--base", base_branch,
|
|
552
|
+
"--head", branch,
|
|
553
|
+
"--title", title,
|
|
554
|
+
"--body-file", "-",
|
|
555
|
+
]
|
|
556
|
+
if draft:
|
|
557
|
+
gh_args.append("--draft")
|
|
558
|
+
pr_result = subprocess.run(
|
|
559
|
+
["gh", *gh_args],
|
|
560
|
+
input=body,
|
|
561
|
+
capture_output=True, text=True, timeout=60,
|
|
562
|
+
cwd=repo_path,
|
|
563
|
+
)
|
|
564
|
+
if pr_result.returncode != 0:
|
|
565
|
+
raise ActionError(f"gh pr create failed: {pr_result.stderr[:400]}")
|
|
566
|
+
pr_url = (pr_result.stdout or "").strip()
|
|
567
|
+
|
|
568
|
+
# 11. Return to base branch so the repo is left in a clean, predictable
|
|
569
|
+
# state for the next caller.
|
|
570
|
+
_run_git(repo_path, ["checkout", base_branch])
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
"pr_url": pr_url,
|
|
574
|
+
"branch": branch,
|
|
575
|
+
"base_branch": base_branch,
|
|
576
|
+
"files_written": written,
|
|
577
|
+
"tests_ran": bool(tests_cmd),
|
|
578
|
+
"draft": bool(draft),
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
ACTION_RUNNERS = {
|
|
583
|
+
"gh_issue_create": _act_gh_issue_create,
|
|
584
|
+
"gh_pr_comment": _act_gh_pr_comment,
|
|
585
|
+
"gh_issue_comment": _act_gh_issue_comment,
|
|
586
|
+
"gh_issue_close": _act_gh_issue_close,
|
|
587
|
+
"gh_issue_reopen": _act_gh_issue_reopen,
|
|
588
|
+
"gh_issue_label": _act_gh_issue_label,
|
|
589
|
+
"gh_pr_ready_for_review": _act_gh_pr_ready_for_review,
|
|
590
|
+
"propose_pr": _act_propose_pr,
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
# ---------------------------------------------------------------------------
|
|
595
|
+
# Executor
|
|
596
|
+
# ---------------------------------------------------------------------------
|
|
597
|
+
|
|
598
|
+
def _append_audit(record: Dict[str, Any]) -> None:
|
|
599
|
+
EXECUTOR_AUDIT.parent.mkdir(parents=True, exist_ok=True)
|
|
600
|
+
try:
|
|
601
|
+
with EXECUTOR_AUDIT.open("a") as fh:
|
|
602
|
+
fh.write(json.dumps(record) + "\n")
|
|
603
|
+
except Exception as exc:
|
|
604
|
+
logger.warning("executor: audit write failed: %s", exc)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _load_work_order(wo_id: str) -> Optional[Dict[str, Any]]:
|
|
608
|
+
jf = WORK_ORDERS_DIR / f"{wo_id}.json"
|
|
609
|
+
if not jf.exists():
|
|
610
|
+
return None
|
|
611
|
+
try:
|
|
612
|
+
return json.loads(jf.read_text())
|
|
613
|
+
except Exception as exc:
|
|
614
|
+
logger.warning("executor: failed to load %s: %s", wo_id, exc)
|
|
615
|
+
return None
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _save_work_order(wo: Dict[str, Any]) -> None:
|
|
619
|
+
jf = WORK_ORDERS_DIR / f"{wo['id']}.json"
|
|
620
|
+
jf.write_text(json.dumps(wo, indent=2))
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def execute_approved(wo_id: str, *, live: bool = False, executed_by: str = "") -> Dict[str, Any]:
|
|
624
|
+
"""Execute an approved work order's executable_actions list.
|
|
625
|
+
|
|
626
|
+
Args:
|
|
627
|
+
wo_id: Work-order id (e.g. WO-2026-04-18-001).
|
|
628
|
+
live: When False (default) the executor returns what it WOULD do
|
|
629
|
+
without running any subprocess. A sanity check before flipping
|
|
630
|
+
the switch.
|
|
631
|
+
executed_by: Agent / user identifier for the audit log.
|
|
632
|
+
|
|
633
|
+
Returns a dict with the overall status plus a per-action log.
|
|
634
|
+
"""
|
|
635
|
+
wo = _load_work_order(wo_id)
|
|
636
|
+
if wo is None:
|
|
637
|
+
return {"ok": False, "error": f"work order {wo_id} not found"}
|
|
638
|
+
|
|
639
|
+
if wo.get("status") != "approved":
|
|
640
|
+
return {
|
|
641
|
+
"ok": False,
|
|
642
|
+
"error": (
|
|
643
|
+
f"work order {wo_id} has status={wo.get('status')!r}; "
|
|
644
|
+
f"executor only runs work orders with status=approved"
|
|
645
|
+
),
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
actions = wo.get("executable_actions") or []
|
|
649
|
+
if not actions:
|
|
650
|
+
return {
|
|
651
|
+
"ok": False,
|
|
652
|
+
"error": (
|
|
653
|
+
f"work order {wo_id} has no executable_actions. The founder "
|
|
654
|
+
f"still needs to run the human steps by hand."
|
|
655
|
+
),
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
errors = validate_actions(actions)
|
|
659
|
+
if errors:
|
|
660
|
+
return {"ok": False, "error": "action validation failed", "details": errors}
|
|
661
|
+
|
|
662
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
663
|
+
log: List[Dict[str, Any]] = []
|
|
664
|
+
|
|
665
|
+
if not live:
|
|
666
|
+
for i, action in enumerate(actions):
|
|
667
|
+
log.append({
|
|
668
|
+
"index": i,
|
|
669
|
+
"action": action["action"],
|
|
670
|
+
"dry_run": True,
|
|
671
|
+
"params_preview": {
|
|
672
|
+
k: (v[:200] if isinstance(v, str) else v)
|
|
673
|
+
for k, v in (action.get("params") or {}).items()
|
|
674
|
+
},
|
|
675
|
+
})
|
|
676
|
+
_append_audit({
|
|
677
|
+
"wo_id": wo_id,
|
|
678
|
+
"ts": now,
|
|
679
|
+
"mode": "dry_run",
|
|
680
|
+
"executed_by": executed_by,
|
|
681
|
+
"action_count": len(actions),
|
|
682
|
+
})
|
|
683
|
+
return {
|
|
684
|
+
"ok": True,
|
|
685
|
+
"mode": "dry_run",
|
|
686
|
+
"wo_id": wo_id,
|
|
687
|
+
"actions": len(actions),
|
|
688
|
+
"log": log,
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
# Live mode: flip status to executing, run each action in order,
|
|
692
|
+
# persist the log both to the local WO file and to Supabase.
|
|
693
|
+
wo["execution_status"] = "executing"
|
|
694
|
+
wo["executed_by"] = executed_by
|
|
695
|
+
_save_work_order(wo)
|
|
696
|
+
|
|
697
|
+
overall_ok = True
|
|
698
|
+
for i, action in enumerate(actions):
|
|
699
|
+
runner = ACTION_RUNNERS[action["action"]]
|
|
700
|
+
started = time.time()
|
|
701
|
+
entry = {"index": i, "action": action["action"], "started_at": datetime.now(timezone.utc).isoformat()}
|
|
702
|
+
try:
|
|
703
|
+
result = runner(action.get("params") or {})
|
|
704
|
+
entry.update({"ok": True, "result": result})
|
|
705
|
+
except ActionError as exc:
|
|
706
|
+
entry.update({"ok": False, "error": str(exc)})
|
|
707
|
+
overall_ok = False
|
|
708
|
+
except Exception as exc: # defensive — never crash the daemon
|
|
709
|
+
entry.update({"ok": False, "error": f"unexpected: {exc}"})
|
|
710
|
+
overall_ok = False
|
|
711
|
+
entry["elapsed_ms"] = int((time.time() - started) * 1000)
|
|
712
|
+
log.append(entry)
|
|
713
|
+
_append_audit({"wo_id": wo_id, **entry, "executed_by": executed_by})
|
|
714
|
+
if not overall_ok:
|
|
715
|
+
break
|
|
716
|
+
|
|
717
|
+
wo["execution_status"] = "executed" if overall_ok else "failed"
|
|
718
|
+
wo["execution_log"] = log
|
|
719
|
+
wo["executed_at"] = datetime.now(timezone.utc).isoformat()
|
|
720
|
+
wo["status"] = "executed" if overall_ok else "failed"
|
|
721
|
+
_save_work_order(wo)
|
|
722
|
+
|
|
723
|
+
# Supabase sync with the new fields.
|
|
724
|
+
try:
|
|
725
|
+
from ai.supabase_sync import sync_work_order
|
|
726
|
+
sync_work_order(wo)
|
|
727
|
+
except Exception:
|
|
728
|
+
pass
|
|
729
|
+
|
|
730
|
+
return {
|
|
731
|
+
"ok": overall_ok,
|
|
732
|
+
"mode": "live",
|
|
733
|
+
"wo_id": wo_id,
|
|
734
|
+
"actions": len(actions),
|
|
735
|
+
"log": log,
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
# ---------------------------------------------------------------------------
|
|
740
|
+
# Polling / autonomous path
|
|
741
|
+
# ---------------------------------------------------------------------------
|
|
742
|
+
|
|
743
|
+
def _is_paused_cloud() -> bool:
|
|
744
|
+
"""Check the Supabase-backed executor_config flag.
|
|
745
|
+
|
|
746
|
+
Lets a Pro user toggle the kill switch from the dashboard without
|
|
747
|
+
shell access to the gateway host. Logical OR with the local file so
|
|
748
|
+
either surface can stop execution. Returns False on any error — the
|
|
749
|
+
LOCAL file remains the last-resort kill switch.
|
|
750
|
+
"""
|
|
751
|
+
try:
|
|
752
|
+
from ai.supabase_sync import _get_client, SUPABASE_URL, SUPABASE_KEY
|
|
753
|
+
import urllib.request
|
|
754
|
+
except Exception:
|
|
755
|
+
return False
|
|
756
|
+
if _get_client() is None:
|
|
757
|
+
return False
|
|
758
|
+
try:
|
|
759
|
+
req = urllib.request.Request(
|
|
760
|
+
f"{SUPABASE_URL}/rest/v1/executor_config?id=eq.default&select=paused",
|
|
761
|
+
headers={"apikey": SUPABASE_KEY, "Authorization": f"Bearer {SUPABASE_KEY}"},
|
|
762
|
+
)
|
|
763
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
764
|
+
rows = json.loads(resp.read().decode())
|
|
765
|
+
return bool(rows and rows[0].get("paused"))
|
|
766
|
+
except Exception as exc:
|
|
767
|
+
logger.debug("executor cloud pause check failed: %s", exc)
|
|
768
|
+
return False
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def is_paused() -> bool:
|
|
772
|
+
"""Charter kill switch: return True if execution is paused.
|
|
773
|
+
|
|
774
|
+
Either the local file (`~/.delimit/pause_executor`) or the cloud
|
|
775
|
+
config flag (`executor_config.paused`) stops the autonomous loop.
|
|
776
|
+
Local wins any disagreement because it's the last-resort signal an
|
|
777
|
+
operator with shell access can trust.
|
|
778
|
+
"""
|
|
779
|
+
if EXECUTOR_PAUSE_FILE.exists():
|
|
780
|
+
return True
|
|
781
|
+
return _is_paused_cloud()
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def list_approved_pending() -> List[Dict[str, Any]]:
|
|
785
|
+
"""Scan Supabase for approved work orders that haven't been executed yet.
|
|
786
|
+
|
|
787
|
+
Returns an empty list on any error (the poller must never crash the
|
|
788
|
+
daemon; a bad cloud read is a no-op).
|
|
789
|
+
"""
|
|
790
|
+
try:
|
|
791
|
+
from ai.supabase_sync import _get_client, SUPABASE_URL, SUPABASE_KEY
|
|
792
|
+
import urllib.request
|
|
793
|
+
except Exception:
|
|
794
|
+
return []
|
|
795
|
+
client = _get_client()
|
|
796
|
+
if client is None:
|
|
797
|
+
return []
|
|
798
|
+
try:
|
|
799
|
+
url = (
|
|
800
|
+
f"{SUPABASE_URL}/rest/v1/work_orders"
|
|
801
|
+
"?status=eq.approved"
|
|
802
|
+
"&or=(execution_status.is.null,execution_status.eq.)"
|
|
803
|
+
"&select=id,status,execution_status,executable_actions"
|
|
804
|
+
"&order=created_at.asc"
|
|
805
|
+
"&limit=20"
|
|
806
|
+
)
|
|
807
|
+
req = urllib.request.Request(
|
|
808
|
+
url,
|
|
809
|
+
headers={
|
|
810
|
+
"apikey": SUPABASE_KEY,
|
|
811
|
+
"Authorization": f"Bearer {SUPABASE_KEY}",
|
|
812
|
+
},
|
|
813
|
+
)
|
|
814
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
815
|
+
return json.loads(resp.read().decode())
|
|
816
|
+
except Exception as exc:
|
|
817
|
+
logger.debug("executor poller: supabase read failed: %s", exc)
|
|
818
|
+
return []
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def poll_and_execute(*, live: bool = False, executed_by: str = "daemon") -> Dict[str, Any]:
|
|
822
|
+
"""One tick of the autonomous executor loop.
|
|
823
|
+
|
|
824
|
+
Looks for approved work orders with a non-empty executable_actions list
|
|
825
|
+
that haven't been executed yet, and runs them. Returns a summary of
|
|
826
|
+
what was attempted this tick. Kill-switch aware.
|
|
827
|
+
"""
|
|
828
|
+
if is_paused():
|
|
829
|
+
return {"paused": True, "reason": f"{EXECUTOR_PAUSE_FILE} exists"}
|
|
830
|
+
|
|
831
|
+
found = list_approved_pending()
|
|
832
|
+
results = []
|
|
833
|
+
for row in found:
|
|
834
|
+
wo_id = row.get("id")
|
|
835
|
+
actions = row.get("executable_actions") or []
|
|
836
|
+
if not wo_id or not actions:
|
|
837
|
+
continue
|
|
838
|
+
# Load local JSON sidecar (source of truth) — fall back to stub.
|
|
839
|
+
wo = _load_work_order(wo_id) or {
|
|
840
|
+
"id": wo_id,
|
|
841
|
+
"status": row.get("status", ""),
|
|
842
|
+
"executable_actions": actions,
|
|
843
|
+
}
|
|
844
|
+
if wo.get("status") != "approved":
|
|
845
|
+
continue
|
|
846
|
+
# Don't double-run anything that has an execution_status already.
|
|
847
|
+
if wo.get("execution_status"):
|
|
848
|
+
continue
|
|
849
|
+
try:
|
|
850
|
+
res = execute_approved(wo_id, live=live, executed_by=executed_by)
|
|
851
|
+
results.append({"wo_id": wo_id, "ok": res.get("ok"), "mode": res.get("mode")})
|
|
852
|
+
except Exception as exc:
|
|
853
|
+
logger.warning("executor poller: %s failed: %s", wo_id, exc)
|
|
854
|
+
results.append({"wo_id": wo_id, "ok": False, "error": str(exc)})
|
|
855
|
+
|
|
856
|
+
return {
|
|
857
|
+
"paused": False,
|
|
858
|
+
"candidates": len(found),
|
|
859
|
+
"attempted": len(results),
|
|
860
|
+
"results": results,
|
|
861
|
+
}
|