Skip to content

Commit e2c4114

Browse files
Dyane681claude
andcommitted
on_scheduled hook: operator-defined post-schedule command
Runs after every successful schedule with the result as JSON on stdin; failures are surfaced but never undo the schedule. Silent on dry runs and failed schedules (tested). Keeps channel-specific tracking glue out of the core - the origin channel's vault integration becomes a hook script instead of a fork. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 485e2da commit e2c4114

6 files changed

Lines changed: 95 additions & 6 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ than guessing.
7070
**Scheduling only, never live.** A badly timed scheduled post can be deleted; a live
7171
post cannot. Going live is deliberately not exposed — reversibility first.
7272

73+
**Extensible where workflows differ, opinionated where they don't.** Every operator
74+
has their own tracking (a wiki to stamp, a review folder to archive, a content
75+
database to update). That glue stays out of the core: set `on_scheduled` in the
76+
config to any command and it runs after each successful schedule with the result as
77+
JSON on stdin (`{video, slot, post_id, series, caption, media_url}`). Hook failures
78+
are surfaced in the result but never undo the schedule. Security note: the hook is
79+
an arbitrary command sourced from your config file — keep `config.json` writable
80+
only by you.
81+
7382
## Tested invariants
7483

7584
`tests/test_invariants.py` (plain pytest, fake API, zero network) pins the promises

docs/case-study.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@ Numbers from the live channel (as of 2026-07-16):
7676
mature. The double-count guard on the series cap was found by the demo script
7777
in this repo, then pinned by a test.
7878

79+
- **Channel glue belongs in a hook, not a fork.** The original pipeline stamps three
80+
wiki files, archives the review copy and feeds a learning ledger after every
81+
schedule. None of that belongs in a generic tool — but all of it fits one
82+
extension point. That observation became the `on_scheduled` hook: the channel's
83+
entire vault integration can run as a ~20-line hook script consuming the JSON
84+
payload, instead of maintaining a diverging fork of the scheduler.
85+
7986
## Next iteration
8087

8188
- Feed per-platform totals into the planner as platforms diverge (the config

examples/config.example.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,6 @@
2626
"ig_user_id": "1784...",
2727
"fb_page_id": "1140..."
2828
},
29-
"planner_sources": []
29+
"planner_sources": [],
30+
"on_scheduled": ""
3031
}

postpeer_pilot/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@
3939
# performance sources
4040
"tiktok_handle": "", # enables the yt-dlp puller when set
4141
"meta": {}, # {"env": "~/.config/meta/.env", "ig_user_id": "...", "fb_page_id": "..."}
42+
# post-schedule hook: shell command run after EVERY successful schedule, with the
43+
# result as JSON on stdin ({video, slot, post_id, series, caption, media_url}).
44+
# Wire your own tracking/archiving here. SECURITY: this executes an arbitrary
45+
# command from the config file — it is an operator decision, keep config.json
46+
# writable only by you. Empty = disabled. Hook failures never undo the schedule.
47+
"on_scheduled": "",
4248
}
4349

4450

postpeer_pilot/scheduler.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
Scheduling only — going live immediately is deliberately NOT offered here; a wrongly
99
timed scheduled post can be deleted, a live post cannot.
1010
"""
11+
import json
12+
import subprocess
1113
from datetime import date
1214
from pathlib import Path
1315

14-
from . import api, plan
16+
from . import api, config, plan
1517

1618

1719
def _caption(video: Path, caption: str | None) -> str:
@@ -30,6 +32,22 @@ def _title(video: Path, title: str | None) -> str | None:
3032
return sidecar.read_text().strip() if sidecar.exists() else None
3133

3234

35+
def _fire_hook(payload: dict) -> dict | None:
36+
"""Run the operator's on_scheduled hook (config) with the schedule result as JSON
37+
on stdin. Fire-and-report: a failing hook is surfaced in the result but NEVER
38+
undoes or fails the schedule itself — the post is already placed."""
39+
cmd = config.load().get("on_scheduled", "")
40+
if not cmd:
41+
return None
42+
try:
43+
p = subprocess.run(cmd, shell=True, input=json.dumps(payload, ensure_ascii=False),
44+
capture_output=True, text=True, timeout=120)
45+
return {"ok": p.returncode == 0,
46+
**({"stderr": p.stderr[-300:]} if p.returncode != 0 else {})}
47+
except Exception as e:
48+
return {"ok": False, "stderr": f"{type(e).__name__}: {e}"}
49+
50+
3351
def _already_scheduled(video: Path) -> str | None:
3452
"""Idempotency guard: has THIS file already been scheduled into a future slot?
3553
Retrying a batch after a partial failure must not double-post the successes."""
@@ -76,11 +94,16 @@ def schedule(videos: list, captions: list | None = None, titles: list | None = N
7694
"orphaned_upload": url, "error": f"{type(e).__name__}: {e}"})
7795
continue
7896
ok = bool(r.get("id") or r.get("success") or r.get("postId"))
97+
result = {"ok": ok, "video": video.name, "slot": slot, "response": r}
7998
if ok:
80-
plan.record(slot, video.name, series,
81-
str(r.get("postId") or r.get("id") or ""), caption=cap)
99+
pid = str(r.get("postId") or r.get("id") or "")
100+
plan.record(slot, video.name, series, pid, caption=cap)
101+
hook = _fire_hook({"video": str(video), "slot": slot, "post_id": pid,
102+
"series": series, "caption": cap, "media_url": url})
103+
if hook is not None:
104+
result["hook"] = hook
82105
else:
83106
taken.pop(slot, None) # post failed -> slot is still free
84-
r = {**r, "orphaned_upload": url}
85-
results.append({"ok": ok, "video": video.name, "slot": slot, "response": r})
107+
result["response"] = {**r, "orphaned_upload": url}
108+
results.append(result)
86109
return results

tests/test_hook.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""on_scheduled hook: fires with the right payload, never breaks scheduling, silent
2+
on dry runs and failures-to-schedule."""
3+
import json
4+
from datetime import date, timedelta
5+
6+
from postpeer_pilot import scheduler
7+
8+
START = date.today() + timedelta(days=30)
9+
10+
11+
def _set_hook(home, cmd):
12+
cfg = json.loads((home / "config.json").read_text())
13+
cfg["on_scheduled"] = cmd
14+
(home / "config.json").write_text(json.dumps(cfg))
15+
16+
17+
def test_hook_receives_payload(home, fake_api, video):
18+
out = home / "hook_payload.json"
19+
_set_hook(home, f"cat > {out}")
20+
r = scheduler.schedule([video("hooked")], series="s1", start=START)
21+
assert r[0]["ok"] and r[0]["hook"]["ok"]
22+
payload = json.loads(out.read_text())
23+
assert payload["slot"] == r[0]["slot"]
24+
assert payload["post_id"] and payload["series"] == "s1"
25+
assert "Caption for hooked" in payload["caption"]
26+
27+
28+
def test_failing_hook_does_not_fail_schedule(home, fake_api, video):
29+
_set_hook(home, "exit 7")
30+
r = scheduler.schedule([video("h2")], start=START)
31+
assert r[0]["ok"] is True # schedule stands
32+
assert r[0]["hook"]["ok"] is False # failure surfaced, not swallowed
33+
assert fake_api.creates == 1
34+
35+
36+
def test_hook_silent_on_dry_run_and_failure(home, fake_api, video):
37+
out = home / "should_not_exist"
38+
_set_hook(home, f"touch {out}")
39+
scheduler.schedule([video("h3")], dry_run=True, start=START)
40+
assert not out.exists() # dry run: no hook
41+
fake_api.fail_creates = 99
42+
r = scheduler.schedule([video("h4")], start=START)
43+
assert r[0]["ok"] is False and not out.exists() # failed schedule: no hook

0 commit comments

Comments
 (0)