@m13v/s4l 1.7.6-rc.6 → 1.7.6-rc.8
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/mcp/dist/index.js +118 -5
- package/mcp/dist/telemetry.js +2 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +36 -29
- package/mcp/menubar/s4l_state.py +17 -121
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/merge_review_queue.py +142 -123
- package/scripts/store_patch.py +111 -0
- package/skill/archive-old-logs.sh +24 -0
package/mcp/dist/index.js
CHANGED
|
@@ -19,7 +19,7 @@ import { screencast, bringBrowserToFront } from "./screencast.js";
|
|
|
19
19
|
import os from "node:os";
|
|
20
20
|
import path from "node:path";
|
|
21
21
|
import fs from "node:fs";
|
|
22
|
-
import { repoDir, runPython, run, readPlan, writePlan, planPath, } from "./repo.js";
|
|
22
|
+
import { repoDir, runPython, run, readPlan, writePlan, planPath, TMP_DIR, } from "./repo.js";
|
|
23
23
|
import { applySetup, resolveProject, hasReadyProject, personaReady, listManagedProjectStatus, listProjectSettings, ensureShortLinksDefault, ensurePersonaProject, findPersonaProject, REQUIRED_FIELDS, RECOMMENDED_FIELDS, configPath, ensureConfigInStateDir, normalizeStringList, recordRedditAccount, } from "./setup.js";
|
|
24
24
|
import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from "./twitterAuth.js";
|
|
25
25
|
import { redditStatus, redditConnect, redditDetectSources, summarizeRedditAuth, } from "./redditAuth.js";
|
|
@@ -929,7 +929,37 @@ function isSandboxCandidate(c) {
|
|
|
929
929
|
const id = Number(c.candidate_id);
|
|
930
930
|
return Number.isFinite(id) && id >= 900_000_000;
|
|
931
931
|
}
|
|
932
|
-
|
|
932
|
+
// Write field patches into the review-queue store UNDER ITS LOCK by shelling
|
|
933
|
+
// to scripts/store_patch.py, which takes the same fcntl.flock the menubar's
|
|
934
|
+
// _store_update and merge_review_queue.py hold around their read-modify-write.
|
|
935
|
+
// Node has no native flock, and this process writing the store directly was
|
|
936
|
+
// the last unlocked writer (the race that erased posted stamps on 2026-07-17).
|
|
937
|
+
// Returns false on any failure so callers can fall back to the legacy write.
|
|
938
|
+
async function patchReviewStore(patches) {
|
|
939
|
+
if (!patches.length)
|
|
940
|
+
return true;
|
|
941
|
+
const tmp = path.join(TMP_DIR, `s4l-store-patches-${process.pid}-${Date.now()}.json`);
|
|
942
|
+
try {
|
|
943
|
+
fs.writeFileSync(tmp, JSON.stringify({ patches }), "utf-8");
|
|
944
|
+
const res = await runPython("scripts/store_patch.py", [tmp], {
|
|
945
|
+
timeoutMs: 30_000,
|
|
946
|
+
env: { S4L_REPO_DIR: repoDir(), PATH: pipelinePath() },
|
|
947
|
+
});
|
|
948
|
+
return res.code === 0;
|
|
949
|
+
}
|
|
950
|
+
catch {
|
|
951
|
+
return false;
|
|
952
|
+
}
|
|
953
|
+
finally {
|
|
954
|
+
try {
|
|
955
|
+
fs.unlinkSync(tmp);
|
|
956
|
+
}
|
|
957
|
+
catch {
|
|
958
|
+
/* best effort */
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
async function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
|
|
933
963
|
// Merge posted/terminal stamps into a FRESH read of the store instead of
|
|
934
964
|
// rewriting the whole plan from the copy taken minutes ago. The old
|
|
935
965
|
// whole-file write was last-writer-wins: while a batch posted, the menubar
|
|
@@ -941,6 +971,46 @@ function mergeApprovedStampsIntoStore(batchId, plan, stamped) {
|
|
|
941
971
|
// overwrites a fresh `posted=true`. Fallback: candidates without a
|
|
942
972
|
// candidate_id can't be matched into the fresh copy, so keep the legacy
|
|
943
973
|
// whole-plan write for those older plans.
|
|
974
|
+
//
|
|
975
|
+
// Review-queue store: go through the LOCKED patch path (store_patch.py)
|
|
976
|
+
// first. The fresh-read merge below closes most of the race window but not
|
|
977
|
+
// all of it — a menubar decision landing between our readPlan and writePlan
|
|
978
|
+
// still gets erased. The locked path holds the store's flock for the whole
|
|
979
|
+
// read-mutate-replace, applies to every sibling row sharing a candidate_id,
|
|
980
|
+
// and enforces the same posted-sticky rules. Legacy path stays as the
|
|
981
|
+
// fallback and for per-batch /tmp plans (single writer, no lock needed).
|
|
982
|
+
try {
|
|
983
|
+
const mergeableForPatch = stamped.every((c) => c.candidate_id !== undefined && c.candidate_id !== null);
|
|
984
|
+
if (batchId === REVIEW_QUEUE_ID && mergeableForPatch) {
|
|
985
|
+
const patches = stamped.map((c) => {
|
|
986
|
+
const set = {};
|
|
987
|
+
const unset = [];
|
|
988
|
+
if (c.posted === true) {
|
|
989
|
+
set.posted = true;
|
|
990
|
+
set.terminal = false;
|
|
991
|
+
if (c.our_url)
|
|
992
|
+
set.our_url = c.our_url;
|
|
993
|
+
unset.push("discard_reason");
|
|
994
|
+
}
|
|
995
|
+
else if (c.terminal === true) {
|
|
996
|
+
set.terminal = true;
|
|
997
|
+
set.terminal_reason = c.terminal_reason ?? null;
|
|
998
|
+
// See the zombie-card note in the legacy branch below: a terminal
|
|
999
|
+
// from a real post attempt must clear the overridable expiry stamp.
|
|
1000
|
+
unset.push("discard_reason");
|
|
1001
|
+
}
|
|
1002
|
+
if (typeof c.post_attempts === "number")
|
|
1003
|
+
set.post_attempts = c.post_attempts;
|
|
1004
|
+
return { candidate_id: c.candidate_id, set, unset };
|
|
1005
|
+
});
|
|
1006
|
+
if (await patchReviewStore(patches))
|
|
1007
|
+
return;
|
|
1008
|
+
console.error("[post] store_patch.py failed; falling back to unlocked stamp merge");
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
catch {
|
|
1012
|
+
/* fall through to the legacy write */
|
|
1013
|
+
}
|
|
944
1014
|
try {
|
|
945
1015
|
const mergeable = stamped.every((c) => c.candidate_id !== undefined && c.candidate_id !== null);
|
|
946
1016
|
const fresh = mergeable ? readPlan(batchId) : null;
|
|
@@ -1290,7 +1360,7 @@ async function postApproved(batchId, plan) {
|
|
|
1290
1360
|
};
|
|
1291
1361
|
}
|
|
1292
1362
|
if (approvedReddit.length)
|
|
1293
|
-
mergeApprovedStampsIntoStore(batchId, plan, approvedReddit);
|
|
1363
|
+
await mergeApprovedStampsIntoStore(batchId, plan, approvedReddit);
|
|
1294
1364
|
return {
|
|
1295
1365
|
attempted: approvedReddit.length,
|
|
1296
1366
|
posted: redditPosted,
|
|
@@ -1560,7 +1630,7 @@ async function postApproved(batchId, plan) {
|
|
|
1560
1630
|
// Reddit stamps (set in the reddit drain above) merge alongside the twitter
|
|
1561
1631
|
// ones: `approved` here spans both platforms.
|
|
1562
1632
|
if (touchedPlan || redditPosted || redditFailed) {
|
|
1563
|
-
mergeApprovedStampsIntoStore(batchId, plan, approved);
|
|
1633
|
+
await mergeApprovedStampsIntoStore(batchId, plan, approved);
|
|
1564
1634
|
}
|
|
1565
1635
|
// Post failures are HANDLED in the pipeline (it returns a count, never throws),
|
|
1566
1636
|
// so they never reach Sentry on their own. Capture an explicit event whenever
|
|
@@ -2805,6 +2875,16 @@ tool("post_drafts", {
|
|
|
2805
2875
|
const total = candidates.length;
|
|
2806
2876
|
const warnings = [];
|
|
2807
2877
|
const inRange = (n) => n >= 1 && n <= total;
|
|
2878
|
+
// Review-queue store: snapshot every row now so the write below can be a
|
|
2879
|
+
// field-level DIFF applied under the store lock (store_patch.py) instead
|
|
2880
|
+
// of an unlocked whole-file replace. This function holds its in-memory
|
|
2881
|
+
// plan across user think-time; a whole-file write here erased any menubar
|
|
2882
|
+
// decision or merge that landed in between (the 2026-07-17 truth-loss
|
|
2883
|
+
// family). Non-store batches keep the plain write: single writer.
|
|
2884
|
+
const isStoreBatch = batch_id === REVIEW_QUEUE_ID;
|
|
2885
|
+
const rowsBefore = isStoreBatch
|
|
2886
|
+
? candidates.map((c) => JSON.stringify(c))
|
|
2887
|
+
: [];
|
|
2808
2888
|
// ---- Rejections: durable + final --------------------------------------
|
|
2809
2889
|
// A rejected draft is marked terminal so it NEVER re-appears for review and is
|
|
2810
2890
|
// never posted. A reject overrides any earlier approve on the same card.
|
|
@@ -2941,7 +3021,40 @@ tool("post_drafts", {
|
|
|
2941
3021
|
if (c)
|
|
2942
3022
|
c.approved = true;
|
|
2943
3023
|
});
|
|
2944
|
-
|
|
3024
|
+
// Persist the decision mutations. Store batch: diff each row against its
|
|
3025
|
+
// snapshot and apply only the changed fields under the store lock, so a
|
|
3026
|
+
// concurrent menubar decision or merge is never erased. Anything else
|
|
3027
|
+
// (per-batch /tmp plans): plain write, single writer.
|
|
3028
|
+
let storeWriteDone = false;
|
|
3029
|
+
if (isStoreBatch) {
|
|
3030
|
+
const patches = [];
|
|
3031
|
+
candidates.forEach((c, i) => {
|
|
3032
|
+
const beforeRaw = rowsBefore[i];
|
|
3033
|
+
const afterRaw = JSON.stringify(c);
|
|
3034
|
+
if (beforeRaw === afterRaw)
|
|
3035
|
+
return;
|
|
3036
|
+
const before = JSON.parse(beforeRaw ?? "{}");
|
|
3037
|
+
const after = JSON.parse(afterRaw);
|
|
3038
|
+
const set = {};
|
|
3039
|
+
const unset = [];
|
|
3040
|
+
for (const k of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
3041
|
+
if (!(k in after) || after[k] === undefined) {
|
|
3042
|
+
if (k in before)
|
|
3043
|
+
unset.push(k);
|
|
3044
|
+
}
|
|
3045
|
+
else if (JSON.stringify(before[k]) !== JSON.stringify(after[k])) {
|
|
3046
|
+
set[k] = after[k];
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
if (Object.keys(set).length || unset.length)
|
|
3050
|
+
patches.push({ candidate_id: c.candidate_id ?? null, n: i + 1, set, unset });
|
|
3051
|
+
});
|
|
3052
|
+
storeWriteDone = await patchReviewStore(patches);
|
|
3053
|
+
if (!storeWriteDone)
|
|
3054
|
+
console.error("[post_drafts] store_patch.py failed; falling back to unlocked plan write");
|
|
3055
|
+
}
|
|
3056
|
+
if (!storeWriteDone)
|
|
3057
|
+
writePlan(batch_id, plan);
|
|
2945
3058
|
if (approve.size === 0) {
|
|
2946
3059
|
return jsonContent({
|
|
2947
3060
|
batch_id,
|
package/mcp/dist/telemetry.js
CHANGED
|
@@ -301,7 +301,8 @@ function collectStateSnapshot() {
|
|
|
301
301
|
["install_progress", "install-progress.json", 64_000],
|
|
302
302
|
["onboarding_progress", "onboarding-progress.json", 256_000],
|
|
303
303
|
["review_queue", "review-queue.json", 256_000],
|
|
304
|
-
|
|
304
|
+
// approved-queue.json removed 2026-07-17: the ledger is gone; the review
|
|
305
|
+
// store is the only local decision record.
|
|
305
306
|
];
|
|
306
307
|
for (const [key, file, cap] of stateFiles) {
|
|
307
308
|
const val = readJsonCapped(path.join(stateDir, file), cap);
|
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.7.6-rc.
|
|
5
|
+
"version": "1.7.6-rc.8",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
|
|
8
8
|
"author": {
|
|
@@ -2331,7 +2331,6 @@ class S4LMenuBar(rumps.App):
|
|
|
2331
2331
|
for it in pending:
|
|
2332
2332
|
batch, n = it.get("batch"), it.get("n")
|
|
2333
2333
|
if n in posted_ns:
|
|
2334
|
-
st.approved_queue_set_status(batch, n, "posted") # reconcile lost update
|
|
2335
2334
|
continue
|
|
2336
2335
|
decision = {
|
|
2337
2336
|
"n": n,
|
|
@@ -2939,11 +2938,14 @@ class S4LMenuBar(rumps.App):
|
|
|
2939
2938
|
flight, and not snoozed (the snooze label and gate take precedence)."""
|
|
2940
2939
|
if pending_count <= 0 or self._review_active:
|
|
2941
2940
|
return 0.0
|
|
2942
|
-
#
|
|
2943
|
-
#
|
|
2944
|
-
#
|
|
2941
|
+
# Canvas never auto-pops (2026-07-17, user direction): the explicit
|
|
2942
|
+
# "Review N pending drafts" click is the ONLY way to open it. Report
|
|
2943
|
+
# the same indefinite hold as the cards-mode "Never" preset so the
|
|
2944
|
+
# menu shows the courtesy line. The reveal cadence itself remains a
|
|
2945
|
+
# cards-mode concept (its menu control is hidden in canvas mode,
|
|
2946
|
+
# 2026-07-16), so a stale cadence value is never consulted here.
|
|
2945
2947
|
if st.read_review_layout() != "cards":
|
|
2946
|
-
return
|
|
2948
|
+
return -1.0
|
|
2947
2949
|
now = time.time()
|
|
2948
2950
|
if now < self._review_snooze_until:
|
|
2949
2951
|
return 0.0
|
|
@@ -3064,12 +3066,20 @@ class S4LMenuBar(rumps.App):
|
|
|
3064
3066
|
# card keeps growing live) and skipped for focus=True (the menu's
|
|
3065
3067
|
# "Review N pending drafts" is an explicit ask). _last_review_sig is NOT
|
|
3066
3068
|
# stamped on a hold, so the same set still presents fresh when due.
|
|
3067
|
-
#
|
|
3068
|
-
#
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3069
|
+
# No panel is open past the _review_active branch above, so the
|
|
3070
|
+
# preference IS the layout that is about to present.
|
|
3071
|
+
if not focus:
|
|
3072
|
+
if st.read_review_layout() != "cards":
|
|
3073
|
+
# Canvas never auto-pops (2026-07-17, user direction): the
|
|
3074
|
+
# explicit "Review N pending drafts" click (focus=True) is the
|
|
3075
|
+
# ONLY way to open the canvas. The extend-open branch above
|
|
3076
|
+
# still grows an already-open canvas live; _last_review_sig is
|
|
3077
|
+
# NOT stamped here, so the whole backlog presents fresh on the
|
|
3078
|
+
# click.
|
|
3079
|
+
return
|
|
3080
|
+
# Cards mode: pace fresh pop-ups by the reveal cadence. The
|
|
3081
|
+
# cadence control is a cards-only concept (hidden while View Mode
|
|
3082
|
+
# is Canvas, 2026-07-16), so it is never consulted for canvas.
|
|
3073
3083
|
cadence = st.read_reveal_cadence()
|
|
3074
3084
|
if cadence < 0:
|
|
3075
3085
|
# "Never" preset: cards never auto-pop, only an explicit
|
|
@@ -3581,7 +3591,6 @@ class S4LMenuBar(rumps.App):
|
|
|
3581
3591
|
# No "Posting draft N…" banner: the menu-bar spinner already shows
|
|
3582
3592
|
# live posting progress, so a Notification Center toast per approved
|
|
3583
3593
|
# card is pure noise. Only failures (below) raise a notification.
|
|
3584
|
-
st.approved_queue_set_status(batch, n, "posting")
|
|
3585
3594
|
with self._review_lock:
|
|
3586
3595
|
activity_label = self._posting_activity_label_locked()
|
|
3587
3596
|
cl = [n] if decision.get("drop_link") else None
|
|
@@ -3613,32 +3622,25 @@ class S4LMenuBar(rumps.App):
|
|
|
3613
3622
|
# one registered). Unlike a real posting failure, nothing
|
|
3614
3623
|
# was ever attempted here — post_drafts never reached a
|
|
3615
3624
|
# server — so there's no double-post risk in retrying.
|
|
3616
|
-
# Deliberately do NOT call store_mark_post_failed
|
|
3617
|
-
#
|
|
3618
|
-
# store_pending_posts()/approved_queue_pending(), which is
|
|
3625
|
+
# Deliberately do NOT call store_mark_post_failed: it would
|
|
3626
|
+
# exclude this card from store_pending_posts(), which is
|
|
3619
3627
|
# exactly what _resume_approved_queue() reads on the next
|
|
3620
|
-
# unreachable->reachable edge. Leaving
|
|
3621
|
-
# that existing restart-recovery
|
|
3622
|
-
# automatically once a server re-registers,
|
|
3623
|
-
# stranding it for manual dashboard retry.
|
|
3624
|
-
st.approved_queue_set_status(batch, n, "queued", error="loopback_unreachable")
|
|
3628
|
+
# unreachable->reachable edge. Leaving the store row
|
|
3629
|
+
# approved-unposted lets that existing restart-recovery
|
|
3630
|
+
# path retry it automatically once a server re-registers,
|
|
3631
|
+
# instead of stranding it for manual dashboard retry.
|
|
3625
3632
|
self._notify(
|
|
3626
3633
|
"S4L", "Server unreachable — will retry automatically once Claude Desktop reconnects."
|
|
3627
3634
|
)
|
|
3628
3635
|
else:
|
|
3629
3636
|
posted = res.get("posted") if isinstance(res, dict) else None
|
|
3630
3637
|
if posted == 0:
|
|
3631
|
-
st.approved_queue_set_status(batch, n, "failed", error="posted_0")
|
|
3632
3638
|
st.store_mark_post_failed(batch, n, decision.get("candidate_id"), "posted_0")
|
|
3633
3639
|
self._notify("S4L", f"Draft {n} didn't post — see the dashboard for why.")
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
# stamps posted/our_url into the store itself; the legacy
|
|
3638
|
-
# set_status only settles pre-store ledger entries.
|
|
3639
|
-
st.approved_queue_set_status(batch, n, "posted")
|
|
3640
|
+
# Success is silent: the spinner + dashboard already reflect
|
|
3641
|
+
# it. No per-card "Posted draft N." banner. The server
|
|
3642
|
+
# stamps posted/our_url into the store itself.
|
|
3640
3643
|
except Exception as e:
|
|
3641
|
-
st.approved_queue_set_status(batch, n, "failed", error=str(e)[:200])
|
|
3642
3644
|
st.store_mark_post_failed(batch, n, decision.get("candidate_id"), str(e)[:200])
|
|
3643
3645
|
sys.stderr.write(f"[s4l-menubar] post draft {n} failed: {e}\n")
|
|
3644
3646
|
sys.stderr.flush()
|
|
@@ -4078,7 +4080,12 @@ class S4LMenuBar(rumps.App):
|
|
|
4078
4080
|
# now regardless.
|
|
4079
4081
|
hold_until = self._reveal_hold_until(pending_count)
|
|
4080
4082
|
if hold_until < 0:
|
|
4081
|
-
|
|
4083
|
+
if st.read_review_layout() == "cards":
|
|
4084
|
+
items.append(self._label("Cards hidden — see count in the menu bar"))
|
|
4085
|
+
else:
|
|
4086
|
+
# Canvas mode: it never auto-opens; the review item above
|
|
4087
|
+
# is the one way in.
|
|
4088
|
+
items.append(self._label("Canvas opens from the review item above"))
|
|
4082
4089
|
elif hold_until:
|
|
4083
4090
|
items.append(
|
|
4084
4091
|
self._label(
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -43,10 +43,6 @@ try:
|
|
|
43
43
|
except Exception:
|
|
44
44
|
pass
|
|
45
45
|
|
|
46
|
-
# Serializes read-modify-write on approved-queue.json. The menu bar's main thread
|
|
47
|
-
# (approve click / restart resume) and the post-worker thread (status updates)
|
|
48
|
-
# both mutate it; without this a concurrent interleave would drop an approval.
|
|
49
|
-
_approved_lock = threading.Lock()
|
|
50
46
|
|
|
51
47
|
# Mirrors shared/onboarding-ledger.cjs MILESTONES (same order). The ledger's
|
|
52
48
|
# OPTIONAL milestones (reddit_connected / reddit_verified) are deliberately NOT
|
|
@@ -982,89 +978,23 @@ def review_queue_posted_count():
|
|
|
982
978
|
return sum(1 for c in cands if candidate_state(c) == "posted")
|
|
983
979
|
|
|
984
980
|
|
|
985
|
-
def _plan_generation(batch):
|
|
986
|
-
"""created_at of the CURRENT review plan for this batch (stamped by
|
|
987
|
-
merge_review_queue.py when it starts a fresh plan), or None for plans
|
|
988
|
-
written before generation stamping existed.
|
|
989
|
-
|
|
990
|
-
Why this matters: the plan lives in /tmp and dies on every reboot or tmp
|
|
991
|
-
sweep, and its candidate numbering restarts at 1, while approved-queue.json
|
|
992
|
-
lives in the state dir forever. Without a generation marker, ledger entries
|
|
993
|
-
from a dead plan match the new plan's low indices and every fresh draft is
|
|
994
|
-
treated as already-decided: the 2026-07-03 "unapproved cards never show up"
|
|
995
|
-
bug (30 stale entries silently swallowed a whole day of drafts)."""
|
|
996
|
-
try:
|
|
997
|
-
p = Path(store_path())
|
|
998
|
-
if not p.exists():
|
|
999
|
-
base = os.environ.get("S4L_TMP_DIR") or "/tmp"
|
|
1000
|
-
p = Path(base) / f"twitter_cycle_plan_{batch}.json"
|
|
1001
|
-
return json.loads(p.read_text()).get("created_at") or None
|
|
1002
|
-
except Exception:
|
|
1003
|
-
return None
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
def _ts_before(a, b):
|
|
1007
|
-
"""True when iso timestamp a is strictly before b. Tolerates the two stamp
|
|
1008
|
-
shapes in play ('...Z' from merge_review_queue, '+00:00' from time_iso)."""
|
|
1009
|
-
try:
|
|
1010
|
-
import datetime
|
|
1011
|
-
|
|
1012
|
-
def parse(s):
|
|
1013
|
-
return datetime.datetime.fromisoformat(str(s).replace("Z", "+00:00"))
|
|
1014
|
-
|
|
1015
|
-
return parse(a) < parse(b)
|
|
1016
|
-
except Exception:
|
|
1017
|
-
return False
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
def _stale_for_plan(it, gen):
|
|
1021
|
-
"""A ledger item that predates the current plan generation refers to a DEAD
|
|
1022
|
-
plan's numbering; its `n` must never match against the live plan."""
|
|
1023
|
-
return bool(gen) and bool(it.get("ts")) and _ts_before(it.get("ts"), gen)
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
def _ledger_items_for_plan(batch, gen):
|
|
1027
|
-
"""Decided ledger items that belong to the CURRENT plan generation."""
|
|
1028
|
-
return [
|
|
1029
|
-
it
|
|
1030
|
-
for it in read_approved_queue()["items"]
|
|
1031
|
-
if it.get("batch") == batch
|
|
1032
|
-
and it.get("status") in ("queued", "posting", "posted", "failed", "rejected")
|
|
1033
|
-
and not _stale_for_plan(it, gen)
|
|
1034
|
-
]
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
981
|
def review_drafts(plan, batch="review-queue"):
|
|
1038
982
|
"""Flatten a plan into the card model: only UNDECIDED candidates. A card that's
|
|
1039
983
|
posted, terminal (rejected/dead), or already approved is a settled decision and
|
|
1040
984
|
must never be re-presented for review (approved ones proceed to post).
|
|
1041
985
|
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
`approved`/`terminal`/`posted` flags are only stamped once the loopback write
|
|
1049
|
-
lands, so without this a card the user just decided would re-present (the exact
|
|
1050
|
-
"I already decided these" bug)."""
|
|
1051
|
-
gen = (plan or {}).get("created_at") or _plan_generation(batch)
|
|
1052
|
-
items = _ledger_items_for_plan(batch, gen)
|
|
1053
|
-
settled_ids = {
|
|
1054
|
-
it.get("candidate_id") for it in items if it.get("candidate_id") is not None
|
|
1055
|
-
}
|
|
1056
|
-
settled_ns = {it.get("n") for it in items if it.get("candidate_id") is None}
|
|
986
|
+
Decision durability lives in the store itself: approve/reject/edit each
|
|
987
|
+
write approved/terminal + the decision payload via store_stamp_decision()
|
|
988
|
+
the INSTANT the user clicks (see s4l_menubar.py::_on_card_decision), under
|
|
989
|
+
the store lock, so candidate_state() alone decides what re-presents. The
|
|
990
|
+
legacy approved-queue.json ledger cross-check that used to live here was
|
|
991
|
+
removed 2026-07-17 along with the rest of that third state store."""
|
|
1057
992
|
out = []
|
|
1058
993
|
for i, c in enumerate(((plan or {}).get("candidates") or [])):
|
|
1059
994
|
# Only awaiting_review rows become cards. This also skips post_failed
|
|
1060
995
|
# rows (decided-but-failed is settled per the docstring above).
|
|
1061
996
|
if candidate_state(c) != "awaiting_review":
|
|
1062
997
|
continue
|
|
1063
|
-
cid = c.get("candidate_id")
|
|
1064
|
-
if cid is not None and cid in settled_ids:
|
|
1065
|
-
continue
|
|
1066
|
-
if (i + 1) in settled_ns:
|
|
1067
|
-
continue
|
|
1068
998
|
out.append(
|
|
1069
999
|
{
|
|
1070
1000
|
"n": i + 1, # 1-based, matches post_drafts numbering
|
|
@@ -1132,35 +1062,13 @@ def review_drafts(plan, batch="review-queue"):
|
|
|
1132
1062
|
return out
|
|
1133
1063
|
|
|
1134
1064
|
|
|
1135
|
-
# ---- durable approved-card queue
|
|
1136
|
-
#
|
|
1137
|
-
#
|
|
1138
|
-
#
|
|
1139
|
-
#
|
|
1140
|
-
#
|
|
1141
|
-
#
|
|
1142
|
-
# queued -> posting -> posted | failed. review_drafts() excludes queued/posting
|
|
1143
|
-
# so an approved card is never re-shown while it drains; a restart re-enqueues
|
|
1144
|
-
# queued/posting items instead of re-presenting them.
|
|
1145
|
-
APPROVED_QUEUE = "approved-queue.json"
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
def read_approved_queue():
|
|
1149
|
-
d = read_json(APPROVED_QUEUE)
|
|
1150
|
-
if not isinstance(d, dict) or not isinstance(d.get("items"), list):
|
|
1151
|
-
return {"items": []}
|
|
1152
|
-
return d
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
def _write_approved_queue(d):
|
|
1156
|
-
try:
|
|
1157
|
-
p = Path(state_dir()) / APPROVED_QUEUE
|
|
1158
|
-
p.parent.mkdir(parents=True, exist_ok=True)
|
|
1159
|
-
tmp = p.with_suffix(".json.tmp")
|
|
1160
|
-
tmp.write_text(json.dumps(d))
|
|
1161
|
-
os.replace(str(tmp), str(p)) # atomic: a crash never leaves a half file
|
|
1162
|
-
except Exception:
|
|
1163
|
-
pass
|
|
1065
|
+
# ---- durable approved-card queue: REMOVED (2026-07-17) ---------------------
|
|
1066
|
+
# approved-queue.json was a second local ledger from before the review store
|
|
1067
|
+
# stamped decisions durably itself (store_stamp_decision, under the store
|
|
1068
|
+
# lock). Once decisions lived in the store, nothing appended to the ledger,
|
|
1069
|
+
# every status write was a no-op over a frozen item list, and its read lane
|
|
1070
|
+
# re-enqueued dead approvals forever. The review store is the ONLY local
|
|
1071
|
+
# decision record now; do not add a second one back.
|
|
1164
1072
|
|
|
1165
1073
|
|
|
1166
1074
|
# ---- Engagement mode (2026-06-26, dual-flag 2026-06-29) -------------------
|
|
@@ -1422,22 +1330,10 @@ def toggle_lane(lane):
|
|
|
1422
1330
|
return write_flags(f["personal_brand"], f["promotion"])
|
|
1423
1331
|
|
|
1424
1332
|
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
for it in d["items"]:
|
|
1430
|
-
if it.get("batch") == batch and it.get("n") == n:
|
|
1431
|
-
it.update(status=status, error=error, ts=time_iso())
|
|
1432
|
-
changed = True
|
|
1433
|
-
if changed:
|
|
1434
|
-
_write_approved_queue(d)
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
# approved_queue_pending() REMOVED (2026-07-17): the legacy approved-queue.json
|
|
1438
|
-
# resume lane was a third source of truth whose entries could never settle (the
|
|
1439
|
-
# ledger stopped being written), so it re-enqueued dead approvals forever. The
|
|
1440
|
-
# store lane (store_pending_posts) is the only resume source now.
|
|
1333
|
+
# approved_queue_set_status() / approved_queue_pending() REMOVED (2026-07-17):
|
|
1334
|
+
# the legacy approved-queue.json ledger is gone entirely — see the removal note
|
|
1335
|
+
# above read-approved-queue's old location. The store lane (store_stamp_decision
|
|
1336
|
+
# + store_pending_posts) is the only decision record and resume source now.
|
|
1441
1337
|
|
|
1442
1338
|
|
|
1443
1339
|
def post_drafts(batch_id, post=None, edits=None, reject=None, clear_link=None, timeout=900, activity_label=None):
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.6-rc.
|
|
3
|
+
"version": "1.7.6-rc.8",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
|
|
6
6
|
"license": "MIT",
|
package/package.json
CHANGED
|
@@ -383,137 +383,156 @@ def main() -> int:
|
|
|
383
383
|
# cycle process and only that process knows them.
|
|
384
384
|
|
|
385
385
|
dst = store_path()
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
386
|
+
# ---- store lock ---------------------------------------------------
|
|
387
|
+
# Hold the SAME fcntl.flock the menubar's _store_update and the MCP's
|
|
388
|
+
# store_patch.py take, for the whole read->merge->write span. Without
|
|
389
|
+
# it this function was a last-writer-wins whole-file rewrite: a card
|
|
390
|
+
# decision stamped between our read (below) and _atomic_write was
|
|
391
|
+
# silently erased (the 2026-07-17 truth-loss family, including the
|
|
392
|
+
# resurrected sandbox approval that posted for real). The lock covers
|
|
393
|
+
# one bulk API call (_sync_with_backend); worst case a menubar decision
|
|
394
|
+
# write blocks for a few seconds once per cycle, which beats losing it.
|
|
395
|
+
import fcntl
|
|
396
|
+
_lk = open(dst + ".lock", "w")
|
|
397
|
+
fcntl.flock(_lk, fcntl.LOCK_EX)
|
|
398
|
+
try:
|
|
399
|
+
existing = []
|
|
400
|
+
plan_created_at = None
|
|
401
|
+
if os.path.exists(dst):
|
|
402
|
+
try:
|
|
403
|
+
with open(dst) as f:
|
|
404
|
+
prev = json.load(f)
|
|
405
|
+
existing = prev.get("candidates") or []
|
|
406
|
+
plan_created_at = prev.get("created_at")
|
|
407
|
+
except Exception:
|
|
408
|
+
existing = []
|
|
409
|
+
plan_created_at = None
|
|
410
|
+
# Absorb a REAL file at the legacy /tmp location (pre-upgrade store, or one
|
|
411
|
+
# written by old code after a reboot) so no pending draft or decision flag
|
|
412
|
+
# is lost, then ensure_store_symlink() below replaces it with the link.
|
|
413
|
+
legacy = plan_path(REVIEW_QUEUE_ID)
|
|
414
|
+
if os.path.exists(legacy) and not os.path.islink(legacy):
|
|
415
|
+
try:
|
|
416
|
+
with open(legacy) as f:
|
|
417
|
+
lp = json.load(f)
|
|
418
|
+
lc = lp.get("candidates") or []
|
|
419
|
+
have = {_dedup_key(c) for c in existing}
|
|
420
|
+
absorbed = [c for c in lc if _dedup_key(c) not in have]
|
|
421
|
+
existing.extend(absorbed)
|
|
422
|
+
plan_created_at = plan_created_at or lp.get("created_at")
|
|
423
|
+
if absorbed:
|
|
424
|
+
print(
|
|
425
|
+
f"[merge_review_queue] absorbed {len(absorbed)} candidate(s) "
|
|
426
|
+
"from legacy /tmp plan into the durable store",
|
|
427
|
+
file=sys.stderr,
|
|
428
|
+
)
|
|
429
|
+
except Exception as e:
|
|
430
|
+
print(f"[merge_review_queue] legacy plan absorb failed: {e}", file=sys.stderr)
|
|
431
|
+
# Generation stamp: set ONLY when starting a fresh plan (the /tmp plan dies
|
|
432
|
+
# on reboot/tmp-sweep and numbering restarts at 1). The menu bar's durable
|
|
433
|
+
# approved-queue ledger uses this to ignore decisions that belong to a dead
|
|
434
|
+
# plan generation; without it, stale (batch, n) entries silently swallow
|
|
435
|
+
# every new draft after a reset. An existing unstamped plan is left
|
|
436
|
+
# unstamped: back-stamping it "now" would invalidate live decisions.
|
|
437
|
+
if not existing and not plan_created_at:
|
|
438
|
+
plan_created_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
439
|
+
|
|
440
|
+
seen = {_dedup_key(c) for c in existing}
|
|
441
|
+
added = 0
|
|
442
|
+
merged = list(existing)
|
|
443
|
+
for c in new_cands:
|
|
444
|
+
k = _dedup_key(c)
|
|
445
|
+
if k in seen:
|
|
446
|
+
continue
|
|
447
|
+
seen.add(k)
|
|
448
|
+
merged.append(c)
|
|
449
|
+
added += 1
|
|
450
|
+
|
|
451
|
+
stamped, pruned = _sync_with_backend(merged)
|
|
452
|
+
if stamped:
|
|
453
|
+
print(f"[merge_review_queue] stamped stats on {stamped} candidate(s)", file=sys.stderr)
|
|
454
|
+
if pruned:
|
|
455
|
+
print(
|
|
456
|
+
f"[merge_review_queue] pruned {pruned} candidate(s) already retired by the "
|
|
457
|
+
"backend (expired/etc.) before they were reviewed",
|
|
458
|
+
file=sys.stderr,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
plan_obj = {"candidates": merged}
|
|
462
|
+
if plan_created_at:
|
|
463
|
+
plan_obj["created_at"] = plan_created_at
|
|
464
|
+
|
|
465
|
+
# This is the actual delivery: if anything below throws, the cycle's drafts
|
|
466
|
+
# were computed but never reached the store the menu bar reads, and the
|
|
467
|
+
# wrapper (run-draft-and-publish.sh) captures this process's whole stdout+
|
|
468
|
+
# stderr with `|| true`, so a crash here previously vanished into a local
|
|
469
|
+
# log nothing central reads — the exact blind spot that cost the 2026-07-08
|
|
470
|
+
# Karol investigation its root cause. Report it like any other handled
|
|
471
|
+
# pipeline failure (see twitter_post_plan.py's post-failure capture).
|
|
402
472
|
try:
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
473
|
+
_atomic_write(dst, plan_obj)
|
|
474
|
+
ensure_store_symlink()
|
|
475
|
+
|
|
476
|
+
# Refresh the review-request marker the menu bar polls. count = cards
|
|
477
|
+
# actually awaiting review (mirrors s4l_state.candidate_state()'s
|
|
478
|
+
# awaiting_review bucket): approved-unposted and post_failed rows are
|
|
479
|
+
# settled decisions, counting them inflated the badge and misled every
|
|
480
|
+
# human/agent reading the marker.
|
|
481
|
+
pending_count = len(
|
|
482
|
+
[
|
|
483
|
+
c
|
|
484
|
+
for c in merged
|
|
485
|
+
if not c.get("posted")
|
|
486
|
+
and not c.get("terminal")
|
|
487
|
+
and not c.get("post_failed")
|
|
488
|
+
and not c.get("approved")
|
|
489
|
+
]
|
|
490
|
+
)
|
|
491
|
+
project = ns.project or batch.get("project") or (new_cands[0].get("matched_project") if new_cands else None)
|
|
492
|
+
_atomic_write(
|
|
493
|
+
review_request_path(),
|
|
494
|
+
{
|
|
495
|
+
"batch_id": REVIEW_QUEUE_ID,
|
|
496
|
+
"project": project,
|
|
497
|
+
"count": pending_count,
|
|
498
|
+
"plan_path": dst,
|
|
499
|
+
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
500
|
+
},
|
|
501
|
+
)
|
|
416
502
|
except Exception as e:
|
|
417
|
-
print(f"[merge_review_queue]
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
continue
|
|
434
|
-
seen.add(k)
|
|
435
|
-
merged.append(c)
|
|
436
|
-
added += 1
|
|
437
|
-
|
|
438
|
-
stamped, pruned = _sync_with_backend(merged)
|
|
439
|
-
if stamped:
|
|
440
|
-
print(f"[merge_review_queue] stamped stats on {stamped} candidate(s)", file=sys.stderr)
|
|
441
|
-
if pruned:
|
|
503
|
+
print(f"[merge_review_queue] delivery failed (drafts NOT merged into cards): {e}", file=sys.stderr)
|
|
504
|
+
try:
|
|
505
|
+
import sentry_init
|
|
506
|
+
|
|
507
|
+
sentry_init.init()
|
|
508
|
+
sentry_init.capture_message(
|
|
509
|
+
f"merge_review_queue delivery failed: {e}",
|
|
510
|
+
level="error",
|
|
511
|
+
tags={"component": "merge_review_queue", "added": str(added)},
|
|
512
|
+
extra={"plan_src": src},
|
|
513
|
+
)
|
|
514
|
+
sentry_init.flush(2.0)
|
|
515
|
+
except Exception:
|
|
516
|
+
pass
|
|
517
|
+
return 1
|
|
518
|
+
|
|
442
519
|
print(
|
|
443
|
-
f"[merge_review_queue]
|
|
444
|
-
"
|
|
520
|
+
f"[merge_review_queue] merged {added} new draft(s) into {REVIEW_QUEUE_ID} "
|
|
521
|
+
f"({pending_count} pending total) from {os.path.basename(src)}",
|
|
445
522
|
file=sys.stderr,
|
|
446
523
|
)
|
|
447
|
-
|
|
448
|
-
plan_obj = {"candidates": merged}
|
|
449
|
-
if plan_created_at:
|
|
450
|
-
plan_obj["created_at"] = plan_created_at
|
|
451
|
-
|
|
452
|
-
# This is the actual delivery: if anything below throws, the cycle's drafts
|
|
453
|
-
# were computed but never reached the store the menu bar reads, and the
|
|
454
|
-
# wrapper (run-draft-and-publish.sh) captures this process's whole stdout+
|
|
455
|
-
# stderr with `|| true`, so a crash here previously vanished into a local
|
|
456
|
-
# log nothing central reads — the exact blind spot that cost the 2026-07-08
|
|
457
|
-
# Karol investigation its root cause. Report it like any other handled
|
|
458
|
-
# pipeline failure (see twitter_post_plan.py's post-failure capture).
|
|
459
|
-
try:
|
|
460
|
-
_atomic_write(dst, plan_obj)
|
|
461
|
-
ensure_store_symlink()
|
|
462
|
-
|
|
463
|
-
# Refresh the review-request marker the menu bar polls. count = cards
|
|
464
|
-
# actually awaiting review (mirrors s4l_state.candidate_state()'s
|
|
465
|
-
# awaiting_review bucket): approved-unposted and post_failed rows are
|
|
466
|
-
# settled decisions, counting them inflated the badge and misled every
|
|
467
|
-
# human/agent reading the marker.
|
|
468
|
-
pending_count = len(
|
|
469
|
-
[
|
|
470
|
-
c
|
|
471
|
-
for c in merged
|
|
472
|
-
if not c.get("posted")
|
|
473
|
-
and not c.get("terminal")
|
|
474
|
-
and not c.get("post_failed")
|
|
475
|
-
and not c.get("approved")
|
|
476
|
-
]
|
|
477
|
-
)
|
|
478
|
-
project = ns.project or batch.get("project") or (new_cands[0].get("matched_project") if new_cands else None)
|
|
479
|
-
_atomic_write(
|
|
480
|
-
review_request_path(),
|
|
481
|
-
{
|
|
482
|
-
"batch_id": REVIEW_QUEUE_ID,
|
|
483
|
-
"project": project,
|
|
484
|
-
"count": pending_count,
|
|
485
|
-
"plan_path": dst,
|
|
486
|
-
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
487
|
-
},
|
|
488
|
-
)
|
|
489
|
-
except Exception as e:
|
|
490
|
-
print(f"[merge_review_queue] delivery failed (drafts NOT merged into cards): {e}", file=sys.stderr)
|
|
524
|
+
# Clean up the consumed batch plan so /tmp doesn't fill with orphans.
|
|
491
525
|
try:
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
sentry_init.init()
|
|
495
|
-
sentry_init.capture_message(
|
|
496
|
-
f"merge_review_queue delivery failed: {e}",
|
|
497
|
-
level="error",
|
|
498
|
-
tags={"component": "merge_review_queue", "added": str(added)},
|
|
499
|
-
extra={"plan_src": src},
|
|
500
|
-
)
|
|
501
|
-
sentry_init.flush(2.0)
|
|
526
|
+
os.remove(src)
|
|
502
527
|
except Exception:
|
|
503
528
|
pass
|
|
504
|
-
return
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
# Clean up the consumed batch plan so /tmp doesn't fill with orphans.
|
|
512
|
-
try:
|
|
513
|
-
os.remove(src)
|
|
514
|
-
except Exception:
|
|
515
|
-
pass
|
|
516
|
-
return 0
|
|
529
|
+
return 0
|
|
530
|
+
finally:
|
|
531
|
+
try:
|
|
532
|
+
fcntl.flock(_lk, fcntl.LOCK_UN)
|
|
533
|
+
except Exception:
|
|
534
|
+
pass
|
|
535
|
+
_lk.close()
|
|
517
536
|
|
|
518
537
|
|
|
519
538
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Locked field-patcher for the review-queue store.
|
|
3
|
+
|
|
4
|
+
The review store (review-queue.json) has three writers: the menubar
|
|
5
|
+
(s4l_state._store_update), merge_review_queue.py, and the MCP server. The
|
|
6
|
+
first two hold an fcntl.flock on `<store>.lock` around their read-modify-write;
|
|
7
|
+
the MCP server is Node, which has no native flock, so it routes its store
|
|
8
|
+
writes through THIS script instead of writing the file itself. That closes the
|
|
9
|
+
last unlocked writer (the last-writer-wins race that erased posted stamps on
|
|
10
|
+
2026-07-17 — six live replies rendered as duplicate_thread_pre_post kills).
|
|
11
|
+
|
|
12
|
+
Protocol-compatible with s4l_state._store_update: same lock file, LOCK_EX for
|
|
13
|
+
the whole read-mutate-replace span, atomic os.replace. fcntl (not an O_EXCL
|
|
14
|
+
lockfile) on purpose: the kernel drops the lock when the holder dies, so there
|
|
15
|
+
is no stale-lock stealing logic to get wrong.
|
|
16
|
+
|
|
17
|
+
Usage: store_patch.py <patches.json> (or '-' to read the JSON from stdin)
|
|
18
|
+
|
|
19
|
+
Input shape:
|
|
20
|
+
{"patches": [
|
|
21
|
+
{"candidate_id": 123, # match ALL rows with this id (ids are NOT
|
|
22
|
+
# unique: sandbox reruns/variant drafts
|
|
23
|
+
# append sibling rows; a stamp that hits
|
|
24
|
+
# only one sibling leaves a drainable twin)
|
|
25
|
+
"n": 4, # 1-based index fallback when candidate_id
|
|
26
|
+
# is absent/None (legacy rows)
|
|
27
|
+
"set": {"approved": true}, # fields to assign
|
|
28
|
+
"unset": ["discard_reason"]} # fields to delete
|
|
29
|
+
]}
|
|
30
|
+
|
|
31
|
+
Merge rules (mirror mergeApprovedStampsIntoStore):
|
|
32
|
+
- posted is sticky: a patch may set posted=true, but `set.terminal=true` is
|
|
33
|
+
ignored on a row whose posted is already true, and posted is never unset.
|
|
34
|
+
Prints {"ok": true, "patched": N} on stdout. Exit 0 even when N=0 (a patch
|
|
35
|
+
matching nothing is not an error; the row may have been absorbed elsewhere).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import fcntl
|
|
41
|
+
import json
|
|
42
|
+
import os
|
|
43
|
+
import sys
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def store_path() -> str:
|
|
47
|
+
state_dir = os.environ.get("S4L_STATE_DIR") or os.path.join(
|
|
48
|
+
os.path.expanduser("~"), ".social-autoposter-mcp"
|
|
49
|
+
)
|
|
50
|
+
return os.path.join(state_dir, "review-queue.json")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _match_rows(cands: list, patch: dict) -> list:
|
|
54
|
+
cid = patch.get("candidate_id")
|
|
55
|
+
if cid is not None:
|
|
56
|
+
rows = [c for c in cands if c.get("candidate_id") == cid]
|
|
57
|
+
if rows:
|
|
58
|
+
return rows
|
|
59
|
+
n = patch.get("n")
|
|
60
|
+
if isinstance(n, int) and 1 <= n <= len(cands):
|
|
61
|
+
return [cands[n - 1]]
|
|
62
|
+
return []
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def apply_patches(data: dict, patches: list) -> int:
|
|
66
|
+
cands = data.get("candidates") or []
|
|
67
|
+
patched = 0
|
|
68
|
+
for p in patches:
|
|
69
|
+
for c in _match_rows(cands, p):
|
|
70
|
+
sets = dict(p.get("set") or {})
|
|
71
|
+
if c.get("posted") is True:
|
|
72
|
+
sets.pop("terminal", None)
|
|
73
|
+
sets.pop("terminal_reason", None)
|
|
74
|
+
for k, v in sets.items():
|
|
75
|
+
if k == "posted" and v is not True and c.get("posted") is True:
|
|
76
|
+
continue # posted is sticky
|
|
77
|
+
c[k] = v
|
|
78
|
+
for k in p.get("unset") or []:
|
|
79
|
+
if k == "posted":
|
|
80
|
+
continue
|
|
81
|
+
c.pop(k, None)
|
|
82
|
+
patched += 1
|
|
83
|
+
return patched
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main() -> int:
|
|
87
|
+
src = sys.argv[1] if len(sys.argv) > 1 else "-"
|
|
88
|
+
raw = sys.stdin.read() if src == "-" else open(src).read()
|
|
89
|
+
patches = (json.loads(raw) or {}).get("patches") or []
|
|
90
|
+
sp = store_path()
|
|
91
|
+
with open(sp + ".lock", "w") as lk:
|
|
92
|
+
fcntl.flock(lk, fcntl.LOCK_EX)
|
|
93
|
+
try:
|
|
94
|
+
try:
|
|
95
|
+
with open(sp) as f:
|
|
96
|
+
data = json.load(f)
|
|
97
|
+
except Exception:
|
|
98
|
+
data = {"candidates": []}
|
|
99
|
+
patched = apply_patches(data, patches)
|
|
100
|
+
tmp = f"{sp}.tmp.{os.getpid()}"
|
|
101
|
+
with open(tmp, "w") as f:
|
|
102
|
+
json.dump(data, f, indent=2)
|
|
103
|
+
os.replace(tmp, sp)
|
|
104
|
+
finally:
|
|
105
|
+
fcntl.flock(lk, fcntl.LOCK_UN)
|
|
106
|
+
print(json.dumps({"ok": True, "patched": patched}))
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
sys.exit(main())
|
|
@@ -33,6 +33,30 @@ log "=== archive-old-logs starting (DAYS=$DAYS) ==="
|
|
|
33
33
|
find "$LOG_DIR" -maxdepth 1 -type f -mtime +"$DAYS" ! -name "$(basename "$RUN_LOG")" -print0 \
|
|
34
34
|
| xargs -0 -I{} mv {} "$ARCHIVE_DIR/" 2>&1 | tee -a "$RUN_LOG" >/dev/null || true
|
|
35
35
|
|
|
36
|
+
# --- Size-based rotation for live launchd-*.log files (2026-07-17) -----------
|
|
37
|
+
# launchd holds these fds open and appends forever, so the mtime sweep above
|
|
38
|
+
# never touches them: their mtime is always fresh. Found in the wild at 1.9GB
|
|
39
|
+
# (launchd-linkedin-stdout.log) and 694MB (launchd-engage-twitter-stdout.log),
|
|
40
|
+
# which made "grep the logs" surface months-stale content as if it were
|
|
41
|
+
# current (the 2026-07-17 Neon-hostname false alarm).
|
|
42
|
+
# Copy-truncate: gzip the current content into logs-archive with a timestamp,
|
|
43
|
+
# then truncate IN PLACE (same inode) so the launchd fd keeps appending.
|
|
44
|
+
# Lines appended during the gzip window are lost on truncate; acceptable for
|
|
45
|
+
# logs, and the window is seconds. Threshold tunable via ROTATE_MAX_MB.
|
|
46
|
+
ROTATE_MAX_MB="${ROTATE_MAX_MB:-10}"
|
|
47
|
+
find "$LOG_DIR" -maxdepth 1 -type f -name "launchd-*.log" -size +"${ROTATE_MAX_MB}M" -print0 \
|
|
48
|
+
| while IFS= read -r -d '' f; do
|
|
49
|
+
base=$(basename "$f" .log)
|
|
50
|
+
dest="$ARCHIVE_DIR/${base}-$(date +%Y%m%d-%H%M%S).log.gz"
|
|
51
|
+
if gzip -c "$f" > "$dest" 2>>"$RUN_LOG"; then
|
|
52
|
+
: > "$f"
|
|
53
|
+
log "rotated $(basename "$f") -> $(basename "$dest") ($(du -h "$dest" | cut -f1) compressed)"
|
|
54
|
+
else
|
|
55
|
+
rm -f "$dest"
|
|
56
|
+
log "WARN: rotate failed for $f (left untouched)"
|
|
57
|
+
fi
|
|
58
|
+
done
|
|
59
|
+
|
|
36
60
|
remaining=$(find "$LOG_DIR" -maxdepth 1 -type f | wc -l | tr -d ' ')
|
|
37
61
|
archived=$(find "$ARCHIVE_DIR" -maxdepth 1 -type f | wc -l | tr -d ' ')
|
|
38
62
|
|