Skip to content

Commit 54039e9

Browse files
committed
feat(cmo): add approved-queue executor with dry-run execution report
1 parent a6f9f51 commit 54039e9

File tree

3 files changed

+237
-0
lines changed

3 files changed

+237
-0
lines changed

ops/cmo-automation/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ What this does today
1212
2) Analyze behavior and output baseline metrics
1313
3) Generate a conservative dry-run engagement queue
1414
4) Score the queue for risk/quality before any execution
15+
5) Build execution report from approved actions (dry-run default)
1516

1617
Current mode
1718
- Dry-run / assisted only.
@@ -25,6 +26,7 @@ Structure
2526
- scripts/generate_engagement_queue.py
2627
- scripts/review_engagement_queue.py
2728
- scripts/reconstruct_fiverr_playbook.py
29+
- scripts/execute_approved_queue.py
2830
- reports/CMO-AUTOMATION-IMPLEMENTATION-PLAN.md
2931
- reports/CMO-CUTOVER-48H-RUNBOOK.md
3032

@@ -38,6 +40,7 @@ Quick start
3840
- python3 scripts/generate_engagement_queue.py
3941
- python3 scripts/review_engagement_queue.py
4042
- python3 scripts/reconstruct_fiverr_playbook.py
43+
- python3 scripts/execute_approved_queue.py
4144

4245
Outputs
4346
- data/latest.json
@@ -46,6 +49,8 @@ Outputs
4649
- data/engagement-queue.json
4750
- reports/cmo-queue-review.json
4851
- reports/cmo-queue-review.md
52+
- reports/cmo-execution-report.json
53+
- reports/cmo-execution-report.md
4954

5055
Guardrails
5156
- Generic short-reply behavior is penalized.
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
#!/usr/bin/env python3
2+
import argparse
3+
import json
4+
import os
5+
import subprocess
6+
from datetime import datetime, timezone
7+
from pathlib import Path
8+
9+
ROOT = Path(__file__).resolve().parents[1]
10+
DEFAULT_INPUT = ROOT / "reports" / "cmo-queue-review.json"
11+
OUT_JSON = ROOT / "reports" / "cmo-execution-report.json"
12+
OUT_MD = ROOT / "reports" / "cmo-execution-report.md"
13+
14+
15+
def load_json(path: Path):
16+
if not path.exists():
17+
raise SystemExit(f"Missing required input: {path}")
18+
return json.loads(path.read_text())
19+
20+
21+
def load_credential_mapping() -> None:
22+
mapping = {
23+
"X_API_KEY": os.getenv("X_API_KEY") or os.getenv("TWITTER_API_KEY"),
24+
"X_API_SECRET": os.getenv("X_API_SECRET") or os.getenv("TWITTER_API_SECRET"),
25+
"X_BEARER_TOKEN": os.getenv("X_BEARER_TOKEN") or os.getenv("TWITTER_BEARER_TOKEN"),
26+
"X_ACCESS_TOKEN": os.getenv("X_ACCESS_TOKEN") or os.getenv("TWITTER_ACCESS_TOKEN"),
27+
"X_ACCESS_TOKEN_SECRET": os.getenv("X_ACCESS_TOKEN_SECRET") or os.getenv("TWITTER_ACCESS_SECRET"),
28+
}
29+
missing = [k for k, v in mapping.items() if not v]
30+
if missing:
31+
raise SystemExit(f"Missing required credentials: {', '.join(missing)}")
32+
os.environ.update(mapping)
33+
34+
35+
def flatten_approved_actions(review: dict) -> list[dict]:
36+
actions: list[dict] = []
37+
for account, payload in review.get("accounts", {}).items():
38+
for action in payload.get("approved_actions", []):
39+
if not action.get("account"):
40+
action = {**action, "account": account}
41+
actions.append(action)
42+
return actions
43+
44+
45+
def classify_action(action: dict) -> tuple[bool, str]:
46+
if isinstance(action.get("x_cli_command"), list) and action["x_cli_command"]:
47+
return True, "explicit_command"
48+
49+
kind = action.get("action")
50+
if kind == "root_post":
51+
text = action.get("post_text") or action.get("text")
52+
if not text:
53+
return False, "missing_root_text"
54+
return False, "missing_x_cli_command"
55+
56+
if kind == "reply":
57+
if not action.get("reply_to_tweet_id") or not (action.get("reply_text") or action.get("text")):
58+
return False, "missing_reply_payload"
59+
return False, "missing_x_cli_command"
60+
61+
return False, "unsupported_action_type"
62+
63+
64+
def run_live(action: dict) -> dict:
65+
cmd = action.get("x_cli_command")
66+
if not isinstance(cmd, list) or not cmd:
67+
return {"status": "blocked", "reason": "missing_x_cli_command"}
68+
69+
proc = subprocess.run(cmd, capture_output=True, text=True)
70+
return {
71+
"status": "executed" if proc.returncode == 0 else "failed",
72+
"returncode": proc.returncode,
73+
"stdout": proc.stdout.strip(),
74+
"stderr": proc.stderr.strip(),
75+
}
76+
77+
78+
def build_execution_plan(review: dict, live: bool = False) -> dict:
79+
actions = flatten_approved_actions(review)
80+
result_actions = []
81+
82+
for idx, action in enumerate(actions, start=1):
83+
ready, reason = classify_action(action)
84+
row = {
85+
"id": idx,
86+
"account": action.get("account"),
87+
"action": action.get("action"),
88+
"target_user": action.get("target_user"),
89+
"ready": ready,
90+
"reason": reason,
91+
"source": action,
92+
}
93+
if live and ready:
94+
row.update(run_live(action))
95+
elif live:
96+
row.update({"status": "blocked"})
97+
else:
98+
row.update({"status": "planned" if ready else "blocked"})
99+
result_actions.append(row)
100+
101+
ready_count = sum(1 for a in result_actions if a["ready"])
102+
executed_count = sum(1 for a in result_actions if a.get("status") == "executed")
103+
failed_count = sum(1 for a in result_actions if a.get("status") == "failed")
104+
105+
plan = {
106+
"generated_at": datetime.now(timezone.utc).isoformat(),
107+
"mode": "live" if live else "dry-run",
108+
"input_review_mode": review.get("mode", "unknown"),
109+
"summary": {
110+
"total_approved": len(actions),
111+
"ready_to_execute": ready_count,
112+
"blocked": len(actions) - ready_count,
113+
"executed": executed_count,
114+
"failed": failed_count,
115+
},
116+
"actions": result_actions,
117+
}
118+
return plan
119+
120+
121+
def write_reports(plan: dict) -> None:
122+
OUT_JSON.write_text(json.dumps(plan, indent=2))
123+
124+
lines = [
125+
"# CMO Approved Queue Execution Report",
126+
f"Generated: {plan['generated_at']}",
127+
f"Mode: {plan['mode']}",
128+
f"Input review mode: {plan['input_review_mode']}",
129+
"",
130+
"## Summary",
131+
f"- total approved: {plan['summary']['total_approved']}",
132+
f"- ready to execute: {plan['summary']['ready_to_execute']}",
133+
f"- blocked: {plan['summary']['blocked']}",
134+
f"- executed: {plan['summary']['executed']}",
135+
f"- failed: {plan['summary']['failed']}",
136+
"",
137+
"## Action status",
138+
]
139+
140+
for row in plan["actions"]:
141+
lines.append(
142+
f"- #{row['id']} @{row.get('account')} {row.get('action')} target={row.get('target_user')} status={row.get('status')} reason={row.get('reason')}"
143+
)
144+
145+
OUT_MD.write_text("\n".join(lines) + "\n")
146+
147+
148+
def parse_args() -> argparse.Namespace:
149+
parser = argparse.ArgumentParser(description="Execute approved CMO queue actions from review output")
150+
parser.add_argument("--input", default=str(DEFAULT_INPUT), help="Path to cmo-queue-review.json")
151+
parser.add_argument("--live", action="store_true", help="Attempt to execute actions with explicit x_cli_command")
152+
return parser.parse_args()
153+
154+
155+
def main() -> None:
156+
args = parse_args()
157+
review = load_json(Path(args.input))
158+
159+
if args.live:
160+
load_credential_mapping()
161+
162+
plan = build_execution_plan(review, live=args.live)
163+
write_reports(plan)
164+
165+
print(str(OUT_JSON))
166+
print(str(OUT_MD))
167+
168+
169+
if __name__ == "__main__":
170+
main()
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import sys
2+
import unittest
3+
from pathlib import Path
4+
5+
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
6+
sys.path.insert(0, str(SCRIPTS))
7+
8+
import execute_approved_queue # noqa: E402
9+
10+
11+
class ExecuteApprovedQueueTests(unittest.TestCase):
12+
def test_flatten_approved_actions_only(self):
13+
review = {
14+
"accounts": {
15+
"TheCesarCross": {
16+
"approved_actions": [
17+
{"account": "TheCesarCross", "action": "root_post"},
18+
{"account": "TheCesarCross", "action": "reply", "target_user": "alice"},
19+
],
20+
"rejected_actions": [
21+
{"account": "TheCesarCross", "action": "reply", "target_user": "bob"}
22+
],
23+
},
24+
"sovren_software": {
25+
"approved_actions": [
26+
{"account": "sovren_software", "action": "reply", "target_user": "carol"}
27+
],
28+
"rejected_actions": [],
29+
},
30+
}
31+
}
32+
33+
actions = execute_approved_queue.flatten_approved_actions(review)
34+
35+
self.assertEqual(3, len(actions))
36+
self.assertEqual("root_post", actions[0]["action"])
37+
self.assertEqual("carol", actions[2]["target_user"])
38+
39+
def test_build_execution_plan_requires_payload(self):
40+
review = {
41+
"mode": "dry-run",
42+
"accounts": {
43+
"TheCesarCross": {
44+
"approved_actions": [
45+
{"account": "TheCesarCross", "action": "root_post"},
46+
{"account": "TheCesarCross", "action": "reply", "target_user": "alice"},
47+
],
48+
}
49+
},
50+
}
51+
52+
plan = execute_approved_queue.build_execution_plan(review, live=False)
53+
54+
self.assertEqual(2, plan["summary"]["total_approved"])
55+
self.assertEqual(0, plan["summary"]["ready_to_execute"])
56+
reasons = {x["reason"] for x in plan["actions"]}
57+
self.assertIn("missing_root_text", reasons)
58+
self.assertIn("missing_reply_payload", reasons)
59+
60+
61+
if __name__ == "__main__":
62+
unittest.main()

0 commit comments

Comments
 (0)