social-autoposter 1.6.88 → 1.6.90
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 +274 -30
- package/mcp/dist/repo.js +63 -0
- package/mcp/dist/telemetry.js +166 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +35 -11
- package/mcp/menubar/s4l_menubar.py +87 -32
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/s4l_box_update.sh +76 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/setup_twitter_auth.py +37 -0
- package/scripts/test_percard_posting.py +123 -0
package/mcp/dist/index.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// stays in the Python/shell scripts; we only orchestrate and present.
|
|
12
12
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
13
13
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
14
15
|
import { z } from "zod";
|
|
15
16
|
import { screencast, bringBrowserToFront } from "./screencast.js";
|
|
16
17
|
import os from "node:os";
|
|
@@ -22,7 +23,7 @@ import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from
|
|
|
22
23
|
import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, ensurePipelineCurrent, } from "./runtime.js";
|
|
23
24
|
import { blockOnboardingMilestone, completeOnboardingMilestone, ensureDoctorPhase, onboardingLedger, onboardingSnapshot, recordOnboardingAttempt, runDoctorPhase, } from "./onboarding.js";
|
|
24
25
|
import { VERSION, versionStatus, latestPublishedVersion } from "./version.js";
|
|
25
|
-
import { initSentry, sendHeartbeat, captureError, flushSentry } from "./telemetry.js";
|
|
26
|
+
import { initSentry, sendHeartbeat, captureError, flushSentry, startLogStreaming, flushLogs } from "./telemetry.js";
|
|
26
27
|
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, getUiCapability, } from "@modelcontextprotocol/ext-apps/server";
|
|
27
28
|
import { fileURLToPath } from "node:url";
|
|
28
29
|
import http from "node:http";
|
|
@@ -531,14 +532,72 @@ function renderDraftsTable(plan) {
|
|
|
531
532
|
})
|
|
532
533
|
.join("\n\n");
|
|
533
534
|
}
|
|
535
|
+
// Resolve the configured posting handle the SAME way account_resolver.py does:
|
|
536
|
+
// AUTOPOSTER_TWITTER_HANDLE env first, then config.json accounts.twitter.handle.
|
|
537
|
+
// Returns the bare handle (no @) or null. The post preflight uses it so a missing
|
|
538
|
+
// handle fails ONCE, loudly, instead of as N silent per-reply no_account_configured
|
|
539
|
+
// skips (twitter_browser.py refuses to post with no handle — no impersonation).
|
|
540
|
+
function readConfiguredTwitterHandle() {
|
|
541
|
+
const env = (process.env.AUTOPOSTER_TWITTER_HANDLE || "").trim().replace(/^@/, "");
|
|
542
|
+
if (env)
|
|
543
|
+
return env;
|
|
544
|
+
try {
|
|
545
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(repoDir(), "config.json"), "utf-8"));
|
|
546
|
+
const h = cfg?.accounts?.twitter?.handle;
|
|
547
|
+
const s = (typeof h === "string" ? h : "").trim().replace(/^@/, "");
|
|
548
|
+
return s || null;
|
|
549
|
+
}
|
|
550
|
+
catch {
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
// Self-heal a missing handle: read the live logged-in @handle from the managed
|
|
555
|
+
// Chrome and persist it to config.json accounts.twitter.handle. This is ground
|
|
556
|
+
// truth (the poster posts through that exact session), NOT a guess — so it's safe
|
|
557
|
+
// where a hardcoded fallback would not be. Closes the onboarding gap where
|
|
558
|
+
// connect_x's best-effort handle capture silently no-op'd and left posting dead.
|
|
559
|
+
// Best-effort; never throws — the caller re-checks and refuses loudly if still unset.
|
|
560
|
+
async function ensurePostingHandle() {
|
|
561
|
+
try {
|
|
562
|
+
await runPython("scripts/setup_twitter_auth.py", ["resolve-handle"], {
|
|
563
|
+
timeoutMs: 60_000,
|
|
564
|
+
env: { SAPS_REPO_DIR: repoDir(), PATH: pipelinePath() },
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
/* best effort */
|
|
569
|
+
}
|
|
570
|
+
}
|
|
534
571
|
async function postApproved(batchId, plan) {
|
|
535
572
|
const approved = (plan.candidates || []).filter((c) => c.approved === true);
|
|
536
573
|
if (approved.length === 0)
|
|
537
574
|
return { attempted: 0, exit_code: 0, summary: "nothing approved" };
|
|
575
|
+
// PREFLIGHT: posting needs a configured @handle, or twitter_browser.py refuses
|
|
576
|
+
// EVERY reply with no_account_configured and the whole batch skips — invisibly.
|
|
577
|
+
// If onboarding never persisted it, self-heal from the live session; if even that
|
|
578
|
+
// can't determine it, refuse here with a clear reason rather than launching a
|
|
579
|
+
// poster that silently burns the whole batch.
|
|
580
|
+
if (!readConfiguredTwitterHandle())
|
|
581
|
+
await ensurePostingHandle();
|
|
582
|
+
if (!readConfiguredTwitterHandle()) {
|
|
583
|
+
return {
|
|
584
|
+
attempted: 0,
|
|
585
|
+
exit_code: 0,
|
|
586
|
+
posted: 0,
|
|
587
|
+
summary: "no_account_configured",
|
|
588
|
+
error: "X is connected but no posting @handle is configured, so every reply would be refused " +
|
|
589
|
+
"(no_account_configured). Re-run project_config action:'connect_x' to capture the handle, " +
|
|
590
|
+
"or set accounts.twitter.handle in config.json.",
|
|
591
|
+
};
|
|
592
|
+
}
|
|
538
593
|
// Posting is a priority over scanning: abort any in-flight plugin scan so the
|
|
539
594
|
// approved post takes the browser immediately instead of waiting on the lock.
|
|
540
595
|
// Plugin pipeline only — never affects the plist autopilot.
|
|
541
596
|
preemptScanForPost();
|
|
597
|
+
// Hold the /tmp shell browser lock (the one the scanner respects) for the WHOLE
|
|
598
|
+
// batch so the every-minute autopilot scan queues behind the post instead of
|
|
599
|
+
// seizing Chrome mid-batch — the root cause of approved batches landing 0/N.
|
|
600
|
+
const heldShellLock = await acquireShellBrowserLock();
|
|
542
601
|
const approvedBatch = `${batchId}_approved`;
|
|
543
602
|
writePlan(approvedBatch, { ...plan, candidates: approved });
|
|
544
603
|
// SAPS_SKIP_CAMPAIGN_SUFFIX=1: manual/reviewed posts from this MCP draft_cycle
|
|
@@ -546,28 +605,62 @@ async function postApproved(batchId, plan) {
|
|
|
546
605
|
// twitter_browser.py's reply handler reads this env (inherited through
|
|
547
606
|
// twitter_post_plan.py's subprocess). The cron pipeline doesn't set it, so the
|
|
548
607
|
// A/B disclosure experiment keeps running on autopilot/cron and on Reddit.
|
|
549
|
-
const res = await
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
608
|
+
const res = await (async () => {
|
|
609
|
+
try {
|
|
610
|
+
return await runPython("scripts/twitter_post_plan.py", ["--plan", planPath(approvedBatch)], {
|
|
611
|
+
timeoutMs: 900_000,
|
|
612
|
+
env: {
|
|
613
|
+
SAPS_SKIP_CAMPAIGN_SUFFIX: "1",
|
|
614
|
+
// Manual approval is an EXCEPTION to the tail-link A/B. The cron pipeline
|
|
615
|
+
// runs TWITTER_TAIL_LINK_RATE=0.9 (from .env) so ~10% of autopilot posts
|
|
616
|
+
// ship link-less as an experiment arm. But when the user hand-reviews a
|
|
617
|
+
// draft, sees the link target in the table, and approves it, dropping the
|
|
618
|
+
// link is surprising and unwanted. Force 1.0 here so every approved draft
|
|
619
|
+
// carries its link. This wins over .env / process.env because run() spreads
|
|
620
|
+
// opts.env AFTER process.env, and twitter_post_plan.py never load_dotenv's
|
|
621
|
+
// with override, so nothing clobbers it. Cron is untouched (it never goes
|
|
622
|
+
// through this MCP path), so the 0.9 experiment keeps running there.
|
|
623
|
+
TWITTER_TAIL_LINK_RATE: "1.0",
|
|
624
|
+
// The poster attaches to the twitter-harness Chrome over CDP. The cron
|
|
625
|
+
// pipeline exports this from skill/lib/twitter-backend.sh; the MCP path
|
|
626
|
+
// must set it explicitly or twitter_browser.py fails with "No twitter-
|
|
627
|
+
// harness Chrome reachable". Honor an inherited value (AppMaker / VM
|
|
628
|
+
// BYO-Chrome), else default to the local harness on port 9555.
|
|
629
|
+
TWITTER_CDP_URL: process.env.TWITTER_CDP_URL || "http://127.0.0.1:9555",
|
|
630
|
+
},
|
|
631
|
+
// Stream the poster's output live (like scan_candidates does) so HANDLED
|
|
632
|
+
// failures — e.g. every reply refused with no_account_configured, which
|
|
633
|
+
// returns a reason instead of throwing — surface in main.log + telemetry
|
|
634
|
+
// in real time. Without this the poster's stdout was buffered in-process
|
|
635
|
+
// and only flushed to post-*.log at the END, so a 0/N batch was invisible
|
|
636
|
+
// while the menu bar showed "posting N/89" climbing.
|
|
637
|
+
onLine: (line) => {
|
|
638
|
+
const t = line.replace(/\s+$/, "");
|
|
639
|
+
if (t.trim())
|
|
640
|
+
console.error(`[post] ${t}`);
|
|
641
|
+
},
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
finally {
|
|
645
|
+
// Always free the lock so a queued scan can proceed, even on throw/timeout.
|
|
646
|
+
if (heldShellLock)
|
|
647
|
+
releaseShellBrowserLock();
|
|
648
|
+
}
|
|
649
|
+
})();
|
|
650
|
+
// Persist the poster's own stdout/stderr to a dated log. Without this the post
|
|
651
|
+
// run was invisible: twitter_post_plan.py's output streamed to this MCP
|
|
652
|
+
// instance's stderr and was never tee'd anywhere on disk, so a 0/N batch left
|
|
653
|
+
// no on-box trace to debug. Best-effort; never breaks posting.
|
|
654
|
+
try {
|
|
655
|
+
const postLogDir = path.join(repoDir(), "skill", "logs");
|
|
656
|
+
fs.mkdirSync(postLogDir, { recursive: true });
|
|
657
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
658
|
+
fs.writeFileSync(path.join(postLogDir, `post-${stamp}.log`), `# post_drafts batch=${batchId} approved=${approved.length} exit=${res.code} ` +
|
|
659
|
+
`shell_lock=${heldShellLock}\n\n=== stdout ===\n${res.stdout}\n\n=== stderr ===\n${res.stderr}\n`);
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
/* best effort */
|
|
663
|
+
}
|
|
571
664
|
let summary = res.stdout.trim();
|
|
572
665
|
try {
|
|
573
666
|
const lines = res.stdout.trim().split("\n");
|
|
@@ -616,6 +709,7 @@ async function postApproved(batchId, plan) {
|
|
|
616
709
|
});
|
|
617
710
|
void flushSentry(2000);
|
|
618
711
|
}
|
|
712
|
+
void flushLogs();
|
|
619
713
|
return {
|
|
620
714
|
attempted: approved.length,
|
|
621
715
|
posted: realPosted,
|
|
@@ -1968,13 +2062,154 @@ let currentScanJob = null;
|
|
|
1968
2062
|
// immediately. Only ever references a scan the MCP server itself launched — never
|
|
1969
2063
|
// the plist autopilot's launchd scan.
|
|
1970
2064
|
let scanChild = null;
|
|
1971
|
-
//
|
|
1972
|
-
//
|
|
1973
|
-
//
|
|
1974
|
-
//
|
|
1975
|
-
//
|
|
1976
|
-
//
|
|
1977
|
-
//
|
|
2065
|
+
// ---- Cross-process browser-lock bridge (the REAL posting-priority fix) ------
|
|
2066
|
+
// The SCANNER (run-twitter-cycle.sh) serializes browser access on a mkdir-based
|
|
2067
|
+
// DIRECTORY lock at /tmp/social-autoposter-twitter-browser.lock (skill/lock.sh).
|
|
2068
|
+
// The POSTER (twitter_post_plan.py / twitter_browser.py) serializes on a totally
|
|
2069
|
+
// SEPARATE json file lock (~/.claude/twitter-browser-lock.json) with role:"post"
|
|
2070
|
+
// preemption. The two locks never reference each other, so a post launched from
|
|
2071
|
+
// THIS MCP (or a sibling MCP instance — every autopilot agent session spawns its
|
|
2072
|
+
// own) never actually excluded a live scan: both held "their" lock and drove the
|
|
2073
|
+
// one shared harness Chrome at once, so an approved batch landed 0/N while a scan
|
|
2074
|
+
// churned 118 queries for ~10min (proven live on the remote box 2026-06-23:
|
|
2075
|
+
// /tmp lock pid=scanner AND json lock python:poster role=post, simultaneously).
|
|
2076
|
+
//
|
|
2077
|
+
// preemptScanForPost's old body only killed scanChild — a process-LOCAL handle to
|
|
2078
|
+
// a scan THIS instance launched. The scan that actually holds the browser is
|
|
2079
|
+
// almost always a SIBLING instance's, which we have no ChildProcess for. So we
|
|
2080
|
+
// bridge to the lock the scanner truly respects: read its /tmp pid file, and if a
|
|
2081
|
+
// live run-twitter-cycle.sh holds it, signal it cross-process. Then the post
|
|
2082
|
+
// HOLDS that same /tmp lock for the whole batch so the every-minute autopilot
|
|
2083
|
+
// scan queues behind us (its acquire_lock waits on our live pid) instead of
|
|
2084
|
+
// seizing Chrome mid-post. skill/lock.sh's ownership guard + kill-0 liveness +
|
|
2085
|
+
// 3h stale-reclaim recover the dir if we ever leak it. Never touches a locked
|
|
2086
|
+
// pipeline script or the python json lock.
|
|
2087
|
+
const TW_BROWSER_LOCK_DIR = "/tmp/social-autoposter-twitter-browser.lock";
|
|
2088
|
+
function shellLockHolderPid() {
|
|
2089
|
+
try {
|
|
2090
|
+
const pid = parseInt(fs.readFileSync(path.join(TW_BROWSER_LOCK_DIR, "pid"), "utf-8").trim(), 10);
|
|
2091
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
2092
|
+
}
|
|
2093
|
+
catch {
|
|
2094
|
+
return null; // no dir / no pid file == lock is free
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
function pidAlive(pid) {
|
|
2098
|
+
try {
|
|
2099
|
+
process.kill(pid, 0);
|
|
2100
|
+
return true;
|
|
2101
|
+
}
|
|
2102
|
+
catch {
|
|
2103
|
+
return false;
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
// True ONLY when pid is a run-twitter-cycle.sh scan — the one holder a post is
|
|
2107
|
+
// allowed to preempt. Never preempt another poster or an unknown holder.
|
|
2108
|
+
function pidIsScan(pid) {
|
|
2109
|
+
try {
|
|
2110
|
+
const cmd = execFileSync("ps", ["-o", "command=", "-p", String(pid)], {
|
|
2111
|
+
encoding: "utf-8",
|
|
2112
|
+
timeout: 4000,
|
|
2113
|
+
});
|
|
2114
|
+
return /run-twitter-cycle\.sh/.test(cmd);
|
|
2115
|
+
}
|
|
2116
|
+
catch {
|
|
2117
|
+
return false;
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
function rmShellLockDir() {
|
|
2121
|
+
try {
|
|
2122
|
+
fs.rmSync(TW_BROWSER_LOCK_DIR, { recursive: true, force: true });
|
|
2123
|
+
}
|
|
2124
|
+
catch {
|
|
2125
|
+
/* best effort */
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2129
|
+
// SIGTERM a live scan holding the shell browser lock so the post takes the
|
|
2130
|
+
// browser at once. Best-effort; only ever targets a run-twitter-cycle.sh.
|
|
2131
|
+
function preemptScanHoldingBrowser() {
|
|
2132
|
+
try {
|
|
2133
|
+
const pid = shellLockHolderPid();
|
|
2134
|
+
if (pid && pidAlive(pid) && pidIsScan(pid)) {
|
|
2135
|
+
console.error(`[post] preempting cross-process scan holding the twitter-browser lock (pid ${pid})`);
|
|
2136
|
+
try {
|
|
2137
|
+
process.kill(pid, "SIGTERM");
|
|
2138
|
+
}
|
|
2139
|
+
catch {
|
|
2140
|
+
/* already gone */
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
catch {
|
|
2145
|
+
/* best effort */
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
// Take the shell browser lock for the batch. Preempts a scan holder; never
|
|
2149
|
+
// steals from a live non-scan holder (a peer poster) — there it returns false
|
|
2150
|
+
// and posting proceeds unguarded (no worse than before). Best-effort.
|
|
2151
|
+
async function acquireShellBrowserLock() {
|
|
2152
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
2153
|
+
try {
|
|
2154
|
+
fs.mkdirSync(TW_BROWSER_LOCK_DIR); // atomic mutex — only one winner
|
|
2155
|
+
try {
|
|
2156
|
+
fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "pid"), String(process.pid));
|
|
2157
|
+
}
|
|
2158
|
+
catch {
|
|
2159
|
+
/* best effort */
|
|
2160
|
+
}
|
|
2161
|
+
try {
|
|
2162
|
+
fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "expires_at"), String(Math.floor(Date.now() / 1000) + 1800));
|
|
2163
|
+
}
|
|
2164
|
+
catch {
|
|
2165
|
+
/* best effort */
|
|
2166
|
+
}
|
|
2167
|
+
console.error(`[post] holding twitter-browser shell lock pid=${process.pid} — scans queue behind the post`);
|
|
2168
|
+
return true;
|
|
2169
|
+
}
|
|
2170
|
+
catch {
|
|
2171
|
+
// Dir exists. Reclaim if the holder is dead; preempt if it's a scan;
|
|
2172
|
+
// otherwise (a live peer poster) leave it and post unguarded.
|
|
2173
|
+
const pid = shellLockHolderPid();
|
|
2174
|
+
if (!pid || !pidAlive(pid)) {
|
|
2175
|
+
rmShellLockDir();
|
|
2176
|
+
}
|
|
2177
|
+
else if (pidIsScan(pid)) {
|
|
2178
|
+
try {
|
|
2179
|
+
process.kill(pid, "SIGTERM");
|
|
2180
|
+
}
|
|
2181
|
+
catch {
|
|
2182
|
+
/* already gone */
|
|
2183
|
+
}
|
|
2184
|
+
await sleepMs(400);
|
|
2185
|
+
rmShellLockDir();
|
|
2186
|
+
}
|
|
2187
|
+
else {
|
|
2188
|
+
return false; // a real peer holds it — don't steal; proceed
|
|
2189
|
+
}
|
|
2190
|
+
await sleepMs(250);
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
return false;
|
|
2194
|
+
}
|
|
2195
|
+
// Release only if it's still OURS (mirror skill/lock.sh's ownership guard) so we
|
|
2196
|
+
// never wipe a scan that legitimately re-acquired after the batch finished.
|
|
2197
|
+
function releaseShellBrowserLock() {
|
|
2198
|
+
try {
|
|
2199
|
+
if (shellLockHolderPid() === process.pid) {
|
|
2200
|
+
rmShellLockDir();
|
|
2201
|
+
console.error(`[post] released twitter-browser shell lock pid=${process.pid}`);
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
catch {
|
|
2205
|
+
/* best effort */
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
// Posting takes priority over scanning. When the user approves a post, abort any
|
|
2209
|
+
// in-flight scan so the browser frees up at once. Two layers: (1) kill scanChild
|
|
2210
|
+
// if THIS instance launched the scan, and (2) cross-process — kill whoever holds
|
|
2211
|
+
// the /tmp shell lock if it's a scan (covers sibling MCP instances we have no
|
|
2212
|
+
// handle to). Best-effort; never throws; never touches a locked pipeline script.
|
|
1978
2213
|
function preemptScanForPost() {
|
|
1979
2214
|
try {
|
|
1980
2215
|
if (scanChild && scanChild.pid && scanChild.exitCode === null) {
|
|
@@ -1994,6 +2229,9 @@ function preemptScanForPost() {
|
|
|
1994
2229
|
// the next scan_candidates call starts fresh.
|
|
1995
2230
|
currentScanJob = null;
|
|
1996
2231
|
scanChild = null;
|
|
2232
|
+
// Cross-process: the scan that actually holds the shared Chrome is usually a
|
|
2233
|
+
// sibling MCP instance's, not ours. Kill it via the lock it truly respects.
|
|
2234
|
+
preemptScanHoldingBrowser();
|
|
1997
2235
|
}
|
|
1998
2236
|
// Per-call long-poll window. Must stay under the ~60s MCP client request timeout
|
|
1999
2237
|
// so every scan_candidates call returns a real response before the client gives
|
|
@@ -2458,6 +2696,11 @@ registerAppResource(server, "S4L product link", PRODUCT_LINK_URI, { mimeType: RE
|
|
|
2458
2696
|
}));
|
|
2459
2697
|
async function main() {
|
|
2460
2698
|
initSentry();
|
|
2699
|
+
// Tee the verbatim stdout/stderr of every pipeline subprocess to the s4l
|
|
2700
|
+
// Cloud Run relay (-> Cloud Logging) so we can troubleshoot/rescue any user
|
|
2701
|
+
// scenario (silent stalls, partial onboarding) without asking them to ship a
|
|
2702
|
+
// log file. Best-effort; disabled with SAPS_LOG_STREAM=0.
|
|
2703
|
+
startLogStreaming();
|
|
2461
2704
|
// A plugin UPDATE refreshes this server (dist/) but not the materialized
|
|
2462
2705
|
// pipeline. Re-extract the bundled pipeline.tgz when it's newer than what's on
|
|
2463
2706
|
// disk, BEFORE serving, so the very first scan uses the shipped pipeline (not
|
|
@@ -2512,6 +2755,7 @@ async function main() {
|
|
|
2512
2755
|
main().catch(async (err) => {
|
|
2513
2756
|
console.error("[social-autoposter-mcp] fatal:", err);
|
|
2514
2757
|
captureError(err, { component: "main" });
|
|
2758
|
+
await flushLogs();
|
|
2515
2759
|
await flushSentry();
|
|
2516
2760
|
process.exit(1);
|
|
2517
2761
|
});
|
package/mcp/dist/repo.js
CHANGED
|
@@ -30,6 +30,27 @@ export function repoDir() {
|
|
|
30
30
|
// "No drafts in batch ...". Default to /tmp to match the script; allow an explicit
|
|
31
31
|
// override for non-standard installs.
|
|
32
32
|
export const TMP_DIR = process.env.SAPS_TMP_DIR || "/tmp";
|
|
33
|
+
let lineSink = null;
|
|
34
|
+
export function setLineSink(fn) {
|
|
35
|
+
lineSink = fn;
|
|
36
|
+
}
|
|
37
|
+
// Derive a short, stable context label for a spawned command so log lines can
|
|
38
|
+
// be grouped by which script produced them (e.g. "scripts/seed_search_topics.py",
|
|
39
|
+
// "skill/run-twitter-cycle.sh"). Best-effort; never throws.
|
|
40
|
+
function deriveContext(cmd, args) {
|
|
41
|
+
try {
|
|
42
|
+
const base = cmd.split("/").pop() || cmd;
|
|
43
|
+
if (/python/i.test(base) || base === "bash" || base === "sh" || base === "node") {
|
|
44
|
+
const first = args.find((a) => !a.startsWith("-"));
|
|
45
|
+
if (first)
|
|
46
|
+
return first;
|
|
47
|
+
}
|
|
48
|
+
return [base, args[0] ?? ""].join(" ").trim();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return cmd;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
33
54
|
// Spawn a process inside the repo, inheriting the repo env (API base + keys
|
|
34
55
|
// come from the install's environment / .env loaded by the scripts themselves).
|
|
35
56
|
//
|
|
@@ -73,6 +94,29 @@ export function run(cmd, args, opts = {}) {
|
|
|
73
94
|
}
|
|
74
95
|
return buf;
|
|
75
96
|
};
|
|
97
|
+
// Parallel whole-line splitter that tees to the telemetry sink (if any),
|
|
98
|
+
// kept separate from the onLine pump so neither path can affect the other.
|
|
99
|
+
const logCtx = opts.logContext || deriveContext(cmd, args);
|
|
100
|
+
let sinkOutBuf = "";
|
|
101
|
+
let sinkErrBuf = "";
|
|
102
|
+
const sinkPump = (chunk, which, buf) => {
|
|
103
|
+
const sink = lineSink;
|
|
104
|
+
if (!sink)
|
|
105
|
+
return buf;
|
|
106
|
+
buf += chunk;
|
|
107
|
+
let nl;
|
|
108
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
109
|
+
const line = buf.slice(0, nl);
|
|
110
|
+
buf = buf.slice(nl + 1);
|
|
111
|
+
try {
|
|
112
|
+
sink(line, which, logCtx);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
/* the telemetry sink must never break the wrapped command */
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return buf;
|
|
119
|
+
};
|
|
76
120
|
let timer;
|
|
77
121
|
if (opts.timeoutMs) {
|
|
78
122
|
timer = setTimeout(() => {
|
|
@@ -83,11 +127,13 @@ export function run(cmd, args, opts = {}) {
|
|
|
83
127
|
const s = d.toString();
|
|
84
128
|
stdout += s;
|
|
85
129
|
outBuf = pump(s, "stdout", outBuf);
|
|
130
|
+
sinkOutBuf = sinkPump(s, "stdout", sinkOutBuf);
|
|
86
131
|
});
|
|
87
132
|
child.stderr.on("data", (d) => {
|
|
88
133
|
const s = d.toString();
|
|
89
134
|
stderr += s;
|
|
90
135
|
errBuf = pump(s, "stderr", errBuf);
|
|
136
|
+
sinkErrBuf = sinkPump(s, "stderr", sinkErrBuf);
|
|
91
137
|
});
|
|
92
138
|
child.on("close", (code) => {
|
|
93
139
|
if (timer)
|
|
@@ -109,6 +155,23 @@ export function run(cmd, args, opts = {}) {
|
|
|
109
155
|
/* ignore */
|
|
110
156
|
}
|
|
111
157
|
}
|
|
158
|
+
const sink = lineSink;
|
|
159
|
+
if (sink) {
|
|
160
|
+
if (sinkOutBuf)
|
|
161
|
+
try {
|
|
162
|
+
sink(sinkOutBuf, "stdout", logCtx);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
/* ignore */
|
|
166
|
+
}
|
|
167
|
+
if (sinkErrBuf)
|
|
168
|
+
try {
|
|
169
|
+
sink(sinkErrBuf, "stderr", logCtx);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
/* ignore */
|
|
173
|
+
}
|
|
174
|
+
}
|
|
112
175
|
resolve({ code: code ?? -1, stdout, stderr });
|
|
113
176
|
});
|
|
114
177
|
child.on("error", (err) => {
|
package/mcp/dist/telemetry.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import * as Sentry from "@sentry/node";
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
import fs from "node:fs";
|
|
12
|
-
import { repoDir, runPython } from "./repo.js";
|
|
12
|
+
import { repoDir, runPython, setLineSink } from "./repo.js";
|
|
13
13
|
import { VERSION } from "./version.js";
|
|
14
14
|
// Sentry DSN is a client-side identifier (safe to embed, same posture as Fazm's
|
|
15
15
|
// hardcoded Swift DSN). Overridable via env for dev. Empty -> Sentry disabled.
|
|
@@ -102,3 +102,168 @@ export async function sendHeartbeat(reason) {
|
|
|
102
102
|
console.error("[social-autoposter-mcp] heartbeat failed:", err?.message || err);
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
+
// ---- Raw subprocess log streaming ------------------------------------------
|
|
106
|
+
// Tees the verbatim stdout/stderr of every pipeline subprocess (via the
|
|
107
|
+
// repo.ts run() boundary) to the s4l Cloud Run relay, which simply
|
|
108
|
+
// console.log()s each line so Cloud Run's runtime ships it to Cloud Logging.
|
|
109
|
+
// No database, no service-account key on the client — the relay is the only
|
|
110
|
+
// thing authenticated to GCP, and it authenticates implicitly via its Cloud
|
|
111
|
+
// Run runtime identity. Lines are buffered in memory and flushed in small
|
|
112
|
+
// batches under the same X-Installation identity the heartbeat uses.
|
|
113
|
+
//
|
|
114
|
+
// Best-effort: NEVER throws into the server, never blocks the child's I/O, and
|
|
115
|
+
// drops on overflow rather than growing unbounded. Disable with
|
|
116
|
+
// SAPS_LOG_STREAM=0.
|
|
117
|
+
//
|
|
118
|
+
// IMPORTANT: logs go to the CLOUD RUN host (AUTOPOSTER_LOG_BASE, default
|
|
119
|
+
// app.s4l.ai), NOT the Vercel host (AUTOPOSTER_API_BASE / s4l.ai) the heartbeat
|
|
120
|
+
// and onboarding-events use. Cloud Run's native stdout -> Cloud Logging path is
|
|
121
|
+
// the whole point of this lane.
|
|
122
|
+
const LOG_STREAM_ENABLED = process.env.SAPS_LOG_STREAM !== "0";
|
|
123
|
+
const LOG_MAX_LINE_LEN = 8192; // mirror the relay cap
|
|
124
|
+
const LOG_MAX_BUFFER = 1000; // drop oldest beyond this (overflow protection)
|
|
125
|
+
const LOG_FLUSH_BATCH = 100; // flush eagerly once we have this many lines
|
|
126
|
+
const LOG_MAX_PER_POST = 200; // relay accepts 1-200 per request
|
|
127
|
+
const LOG_FLUSH_MS = 3000; // otherwise flush on this cadence
|
|
128
|
+
// Drop genuinely useless high-volume lines before they ever buffer, so a chatty
|
|
129
|
+
// run doesn't crowd out the signal (and to keep Cloud Logging volume sane).
|
|
130
|
+
// Empty/whitespace-only lines plus an env-extensible regex of obvious dump
|
|
131
|
+
// signatures. Deliberately conservative: real pipeline output is the value, so
|
|
132
|
+
// we only filter clear noise. Extend via SAPS_LOG_NOISE_RE (a JS regex source).
|
|
133
|
+
let logNoiseRe = null;
|
|
134
|
+
try {
|
|
135
|
+
const extra = (process.env.SAPS_LOG_NOISE_RE || "").trim();
|
|
136
|
+
// Default signatures: a bare `ps`/`launchctl`-style table dump row is rare in
|
|
137
|
+
// pipeline output but floods when an agent shells one in. Keep this tight.
|
|
138
|
+
const sources = [
|
|
139
|
+
extra,
|
|
140
|
+
].filter(Boolean);
|
|
141
|
+
logNoiseRe = sources.length ? new RegExp(sources.join("|")) : null;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
logNoiseRe = null;
|
|
145
|
+
}
|
|
146
|
+
function isNoise(line) {
|
|
147
|
+
if (!line || !line.trim())
|
|
148
|
+
return true; // blank / whitespace-only
|
|
149
|
+
if (logNoiseRe && logNoiseRe.test(line))
|
|
150
|
+
return true;
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
const logBuffer = [];
|
|
154
|
+
let logDropped = 0; // count of lines dropped on overflow (surfaced periodically)
|
|
155
|
+
let logFlushing = false;
|
|
156
|
+
let logTimer;
|
|
157
|
+
let cachedInstallHeader = null;
|
|
158
|
+
let logStreamingStarted = false;
|
|
159
|
+
async function installHeader() {
|
|
160
|
+
if (cachedInstallHeader)
|
|
161
|
+
return cachedInstallHeader;
|
|
162
|
+
try {
|
|
163
|
+
const idScript = path.join(repoDir(), "scripts", "identity.py");
|
|
164
|
+
if (!fs.existsSync(idScript))
|
|
165
|
+
return null;
|
|
166
|
+
const res = await runPython("scripts/identity.py", ["header"], { timeoutMs: 10_000 });
|
|
167
|
+
const header = (res.stdout || "").trim();
|
|
168
|
+
if (res.code === 0 && header) {
|
|
169
|
+
cachedInstallHeader = header;
|
|
170
|
+
return header;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
/* best-effort */
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
// Buffer one raw line. Called from the repo.ts line sink, so it must be cheap
|
|
179
|
+
// and total non-throwing.
|
|
180
|
+
export function logLine(stream, line, context) {
|
|
181
|
+
if (!LOG_STREAM_ENABLED)
|
|
182
|
+
return;
|
|
183
|
+
try {
|
|
184
|
+
if (isNoise(line))
|
|
185
|
+
return;
|
|
186
|
+
logBuffer.push({
|
|
187
|
+
ts: new Date().toISOString(),
|
|
188
|
+
stream,
|
|
189
|
+
line: line.length > LOG_MAX_LINE_LEN ? line.slice(0, LOG_MAX_LINE_LEN) : line,
|
|
190
|
+
context: context || "",
|
|
191
|
+
});
|
|
192
|
+
if (logBuffer.length > LOG_MAX_BUFFER) {
|
|
193
|
+
// Drop oldest to bound memory; the newest lines are the most useful.
|
|
194
|
+
logDropped += logBuffer.length - LOG_MAX_BUFFER;
|
|
195
|
+
logBuffer.splice(0, logBuffer.length - LOG_MAX_BUFFER);
|
|
196
|
+
}
|
|
197
|
+
if (logBuffer.length >= LOG_FLUSH_BATCH)
|
|
198
|
+
void flushLogs();
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
/* never throw into the run() boundary */
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
export async function flushLogs() {
|
|
205
|
+
if (!LOG_STREAM_ENABLED)
|
|
206
|
+
return;
|
|
207
|
+
if (logFlushing || logBuffer.length === 0)
|
|
208
|
+
return;
|
|
209
|
+
logFlushing = true;
|
|
210
|
+
try {
|
|
211
|
+
const header = await installHeader();
|
|
212
|
+
if (!header)
|
|
213
|
+
return; // runtime not unpacked yet; keep buffering
|
|
214
|
+
// Cloud Run relay host (NOT the Vercel API host). app.s4l.ai serves
|
|
215
|
+
// bin/server.js, whose POST /api/v1/installations/logs console.log()s each
|
|
216
|
+
// line into Cloud Logging.
|
|
217
|
+
const base = (process.env.AUTOPOSTER_LOG_BASE || "https://app.s4l.ai").replace(/\/+$/, "");
|
|
218
|
+
// Drain in <=200-line POSTs until the buffer empties (or a POST fails).
|
|
219
|
+
while (logBuffer.length > 0) {
|
|
220
|
+
const batch = logBuffer.splice(0, LOG_MAX_PER_POST);
|
|
221
|
+
const lines = batch.map((b) => ({
|
|
222
|
+
ts: b.ts,
|
|
223
|
+
stream: b.stream,
|
|
224
|
+
line: b.line,
|
|
225
|
+
context: b.context || undefined,
|
|
226
|
+
}));
|
|
227
|
+
try {
|
|
228
|
+
const resp = await fetch(`${base}/api/v1/installations/logs`, {
|
|
229
|
+
method: "POST",
|
|
230
|
+
headers: { "X-Installation": header, "content-type": "application/json" },
|
|
231
|
+
body: JSON.stringify({ lines }),
|
|
232
|
+
signal: AbortSignal.timeout(15_000),
|
|
233
|
+
});
|
|
234
|
+
if (!resp.ok) {
|
|
235
|
+
// Drop this batch (don't re-buffer): a persistent 4xx/5xx would grow
|
|
236
|
+
// the buffer unbounded. The raw stream is best-effort.
|
|
237
|
+
console.error(`[social-autoposter-mcp] log flush http ${resp.status}`);
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
// Network blip: drop this batch, stop draining, try again next tick.
|
|
243
|
+
console.error("[social-autoposter-mcp] log flush failed:", err?.message || err);
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (logDropped > 0) {
|
|
248
|
+
console.error(`[social-autoposter-mcp] log stream dropped ${logDropped} line(s) on overflow`);
|
|
249
|
+
logDropped = 0;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
logFlushing = false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Register the repo.ts line sink and start the periodic flush. Idempotent.
|
|
257
|
+
export function startLogStreaming() {
|
|
258
|
+
if (!LOG_STREAM_ENABLED || logStreamingStarted)
|
|
259
|
+
return;
|
|
260
|
+
logStreamingStarted = true;
|
|
261
|
+
try {
|
|
262
|
+
setLineSink((line, stream, context) => logLine(stream, line, context));
|
|
263
|
+
logTimer = setInterval(() => void flushLogs(), LOG_FLUSH_MS);
|
|
264
|
+
logTimer.unref();
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
console.error("[social-autoposter-mcp] log streaming start failed:", err?.message || err);
|
|
268
|
+
}
|
|
269
|
+
}
|
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.6.
|
|
5
|
+
"version": "1.6.90",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts. Thin desktop client over the S4L pipeline.",
|
|
7
7
|
"long_description": "A guided assistant that drafts, reviews, and autopilots X/Twitter posts.\nTo get started:\n1. Click **Configure** and set every tool permission to **Always Allow**.\n2. Copy this prompt: **Set me up on S4L end to end**.\n3. Quit fully with CMD+Q, restart Claude, and paste the prompt into a new chat.",
|
|
8
8
|
"author": {
|
package/mcp/menubar/s4l_card.py
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"""Corner pop-up review cards for draft approval (AppKit / pyobjc).
|
|
2
2
|
|
|
3
|
-
`present_review(drafts, on_complete)` shows one small floating panel
|
|
4
|
-
the top-right corner: thread context, an EDITABLE reply field, a
|
|
5
|
-
Reject / Approve
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
`present_review(drafts, on_decision, on_complete)` shows one small floating panel
|
|
4
|
+
per draft in the top-right corner: thread context, an EDITABLE reply field, a
|
|
5
|
+
counter, and Reject / Approve. `on_decision` fires the INSTANT each card is
|
|
6
|
+
approved/rejected (so an approved draft can post right away), and `on_complete`
|
|
7
|
+
fires once the last card is decided or the window is closed. The whole AppKit
|
|
8
|
+
surface is isolated behind that one function so the menu bar wiring doesn't
|
|
9
|
+
depend on the windowing details.
|
|
8
10
|
|
|
9
11
|
Decision shape: {"n": int, "approved": bool, "text": str, "edited": bool}
|
|
10
12
|
|
|
@@ -70,11 +72,12 @@ def _label(frame, text, *, size=12, bold=False, muted=False):
|
|
|
70
72
|
|
|
71
73
|
|
|
72
74
|
class _ReviewController(NSObject):
|
|
73
|
-
def
|
|
75
|
+
def initWithDrafts_onDecision_onComplete_(self, drafts, on_decision, on_complete):
|
|
74
76
|
self = objc.super(_ReviewController, self).init()
|
|
75
77
|
if self is None:
|
|
76
78
|
return None
|
|
77
79
|
self._drafts = list(drafts)
|
|
80
|
+
self._on_decision = on_decision
|
|
78
81
|
self._on_complete = on_complete
|
|
79
82
|
self._idx = 0
|
|
80
83
|
self._decisions = []
|
|
@@ -218,13 +221,29 @@ class _ReviewController(NSObject):
|
|
|
218
221
|
else:
|
|
219
222
|
self._render()
|
|
220
223
|
|
|
224
|
+
@objc.python_method
|
|
225
|
+
def _fire_decision(self):
|
|
226
|
+
# Fire the per-card callback the instant a decision is made, so an
|
|
227
|
+
# approved draft starts posting immediately instead of waiting for the
|
|
228
|
+
# whole batch to be reviewed. A throwing callback must never break the
|
|
229
|
+
# card flow (or the panel would wedge on the current card).
|
|
230
|
+
cb = self._on_decision
|
|
231
|
+
if cb is None or not self._decisions:
|
|
232
|
+
return
|
|
233
|
+
try:
|
|
234
|
+
cb(dict(self._decisions[-1]))
|
|
235
|
+
except Exception:
|
|
236
|
+
pass
|
|
237
|
+
|
|
221
238
|
# ObjC selectors (trailing underscore -> "approve:" etc.)
|
|
222
239
|
def approve_(self, sender):
|
|
223
240
|
self._record(True)
|
|
241
|
+
self._fire_decision()
|
|
224
242
|
self._advance()
|
|
225
243
|
|
|
226
244
|
def reject_(self, sender):
|
|
227
245
|
self._record(False)
|
|
246
|
+
self._fire_decision()
|
|
228
247
|
self._advance()
|
|
229
248
|
|
|
230
249
|
def windowShouldClose_(self, sender):
|
|
@@ -252,12 +271,17 @@ class _ReviewController(NSObject):
|
|
|
252
271
|
_active = None
|
|
253
272
|
|
|
254
273
|
|
|
255
|
-
def present_review(drafts, on_complete):
|
|
274
|
+
def present_review(drafts, on_decision=None, on_complete=None):
|
|
256
275
|
"""Show the review cards (main thread only). drafts: list of
|
|
257
|
-
{n, thread_author, thread_text, reply_text, link_url}.
|
|
258
|
-
fires
|
|
276
|
+
{n, thread_author, thread_text, reply_text, link_url}.
|
|
277
|
+
on_decision(decision) fires the instant each card is approved/rejected (so an
|
|
278
|
+
approved draft posts right away); on_complete(decisions) fires when the user
|
|
279
|
+
finishes the last card or closes the window. Both run on the main thread."""
|
|
259
280
|
global _active
|
|
260
281
|
if not drafts:
|
|
261
|
-
on_complete
|
|
282
|
+
if on_complete is not None:
|
|
283
|
+
on_complete([])
|
|
262
284
|
return
|
|
263
|
-
_active = _ReviewController.alloc().
|
|
285
|
+
_active = _ReviewController.alloc().initWithDrafts_onDecision_onComplete_(
|
|
286
|
+
drafts, on_decision, on_complete
|
|
287
|
+
)
|
|
@@ -18,6 +18,7 @@ rather than rumps.notification (which needs a bundle id).
|
|
|
18
18
|
|
|
19
19
|
import json
|
|
20
20
|
import os
|
|
21
|
+
import queue
|
|
21
22
|
import subprocess
|
|
22
23
|
import sys
|
|
23
24
|
import tempfile
|
|
@@ -152,6 +153,17 @@ class S4LMenuBar(rumps.App):
|
|
|
152
153
|
# later batch for the life of this process (only a restart cleared it),
|
|
153
154
|
# which is exactly the "drafts queued but no cards" bug.
|
|
154
155
|
self._last_review_sig = None
|
|
156
|
+
# Per-card posting. Each approved card posts the INSTANT it's approved,
|
|
157
|
+
# serialized through one persistent worker so two posts never drive the
|
|
158
|
+
# shared harness Chrome at once (the poster lock fails a concurrent peer
|
|
159
|
+
# after 45s rather than queuing it, which would land the 2nd card 0/N).
|
|
160
|
+
# `_review_active` stays true while the panel is open OR posts are still
|
|
161
|
+
# draining, so a half-posted set is never re-presented as fresh cards.
|
|
162
|
+
self._post_q = queue.Queue()
|
|
163
|
+
self._post_worker = None
|
|
164
|
+
self._review_lock = threading.Lock()
|
|
165
|
+
self._panel_open = False
|
|
166
|
+
self._posts_outstanding = 0
|
|
155
167
|
self._spin_i = 0
|
|
156
168
|
self._spinner = None # fast rumps.Timer animating the title while busy
|
|
157
169
|
# Reliable self-check of our own Accessibility (TCC) grant — this is the
|
|
@@ -467,11 +479,14 @@ class S4LMenuBar(rumps.App):
|
|
|
467
479
|
if sig == self._last_review_sig:
|
|
468
480
|
return
|
|
469
481
|
self._review_active = True
|
|
482
|
+
self._panel_open = True
|
|
470
483
|
try:
|
|
471
484
|
import s4l_card
|
|
472
485
|
|
|
473
486
|
s4l_card.present_review(
|
|
474
|
-
drafts,
|
|
487
|
+
drafts,
|
|
488
|
+
on_decision=lambda d: self._on_card_decision(batch, d),
|
|
489
|
+
on_complete=lambda decisions: self._on_review_closed(batch, decisions),
|
|
475
490
|
)
|
|
476
491
|
# Record as shown only AFTER the cards are actually up, so a transient
|
|
477
492
|
# card-UI failure never permanently suppresses this pending set.
|
|
@@ -479,41 +494,80 @@ class S4LMenuBar(rumps.App):
|
|
|
479
494
|
except Exception as e:
|
|
480
495
|
# Card UI unavailable — don't strand the batch; chat review still works.
|
|
481
496
|
self._review_active = False
|
|
497
|
+
self._panel_open = False
|
|
482
498
|
sys.stderr.write(f"[s4l-menubar] review cards failed: {e}\n")
|
|
483
499
|
sys.stderr.flush()
|
|
484
500
|
_capture(e, phase="review_cards")
|
|
485
501
|
|
|
486
|
-
def
|
|
487
|
-
# Runs on the main thread
|
|
488
|
-
#
|
|
489
|
-
#
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
502
|
+
def _on_card_decision(self, batch, decision):
|
|
503
|
+
# Runs on the main thread the INSTANT a card is approved/rejected. An
|
|
504
|
+
# approved card is enqueued for immediate posting; a rejected card does
|
|
505
|
+
# nothing. We never post inline here — posting can take minutes and would
|
|
506
|
+
# freeze the card UI while the user reviews the rest of the stack.
|
|
507
|
+
if not decision.get("approved"):
|
|
508
|
+
return
|
|
509
|
+
with self._review_lock:
|
|
510
|
+
self._posts_outstanding += 1
|
|
511
|
+
self._review_active = True
|
|
512
|
+
self._post_q.put((batch, decision))
|
|
513
|
+
self._ensure_post_worker()
|
|
514
|
+
|
|
515
|
+
def _on_review_closed(self, batch, decisions):
|
|
516
|
+
# Fires when the card sequence ends (last card decided or window closed).
|
|
517
|
+
# The panel is gone, but approved cards may still be draining — keep the
|
|
518
|
+
# review "active" until the queue empties so the not-yet-posted remainder
|
|
519
|
+
# isn't re-presented as a fresh batch.
|
|
520
|
+
with self._review_lock:
|
|
521
|
+
self._panel_open = False
|
|
522
|
+
if self._posts_outstanding <= 0:
|
|
523
|
+
self._review_active = False
|
|
493
524
|
st.clear_review_request()
|
|
494
|
-
if not approved:
|
|
495
|
-
self._review_active = False
|
|
525
|
+
if not any(d.get("approved") for d in decisions):
|
|
496
526
|
self._notify("S4L", "No drafts approved — nothing posted.")
|
|
497
|
-
return
|
|
498
|
-
self._notify("S4L", f"Posting {len(approved)} draft(s)…")
|
|
499
|
-
|
|
500
|
-
# The server's post_drafts writes "posting" activity, so the activity
|
|
501
|
-
# spinner shows automatically while this runs — no local spinner needed.
|
|
502
|
-
def work():
|
|
503
|
-
res = st.post_drafts(batch, post=post_nums, edits=edits)
|
|
504
|
-
if res is None:
|
|
505
|
-
self._notify(
|
|
506
|
-
"S4L", "Couldn't post — open Claude Desktop and try the draft again."
|
|
507
|
-
)
|
|
508
|
-
else:
|
|
509
|
-
posted = res.get("posted") if isinstance(res, dict) else None
|
|
510
|
-
self._notify(
|
|
511
|
-
"S4L",
|
|
512
|
-
f"Posted {posted if posted is not None else len(approved)} draft(s).",
|
|
513
|
-
)
|
|
514
|
-
self._review_active = False
|
|
515
527
|
|
|
516
|
-
|
|
528
|
+
def _ensure_post_worker(self):
|
|
529
|
+
# One persistent daemon worker drains the approved-card queue. It never
|
|
530
|
+
# exits (avoids an enqueue-vs-exit race) — an idle parked thread is cheap.
|
|
531
|
+
if self._post_worker is not None and self._post_worker.is_alive():
|
|
532
|
+
return
|
|
533
|
+
self._post_worker = threading.Thread(target=self._post_worker_loop, daemon=True)
|
|
534
|
+
self._post_worker.start()
|
|
535
|
+
|
|
536
|
+
def _post_worker_loop(self):
|
|
537
|
+
# Serialized poster: one approved card at a time so two posts never drive
|
|
538
|
+
# the shared harness Chrome simultaneously. The server's post_drafts writes
|
|
539
|
+
# "posting" activity, so the menu-bar spinner shows automatically.
|
|
540
|
+
while True:
|
|
541
|
+
batch, decision = self._post_q.get() # blocks until a card is approved
|
|
542
|
+
n = decision.get("n")
|
|
543
|
+
try:
|
|
544
|
+
self._notify("S4L", f"Posting draft {n}…")
|
|
545
|
+
if decision.get("edited"):
|
|
546
|
+
res = st.post_drafts(
|
|
547
|
+
batch, edits=[{"n": n, "text": decision.get("text") or ""}]
|
|
548
|
+
)
|
|
549
|
+
else:
|
|
550
|
+
res = st.post_drafts(batch, post=[n])
|
|
551
|
+
if res is None:
|
|
552
|
+
self._notify(
|
|
553
|
+
"S4L", "Couldn't post — open Claude Desktop and try the draft again."
|
|
554
|
+
)
|
|
555
|
+
else:
|
|
556
|
+
posted = res.get("posted") if isinstance(res, dict) else None
|
|
557
|
+
if posted == 0:
|
|
558
|
+
self._notify("S4L", f"Draft {n} didn't post — see the dashboard for why.")
|
|
559
|
+
else:
|
|
560
|
+
self._notify("S4L", f"Posted draft {n}.")
|
|
561
|
+
except Exception as e:
|
|
562
|
+
sys.stderr.write(f"[s4l-menubar] post draft {n} failed: {e}\n")
|
|
563
|
+
sys.stderr.flush()
|
|
564
|
+
_capture(e, phase="post_card")
|
|
565
|
+
finally:
|
|
566
|
+
with self._review_lock:
|
|
567
|
+
self._posts_outstanding -= 1
|
|
568
|
+
if self._posts_outstanding <= 0 and not self._panel_open:
|
|
569
|
+
self._review_active = False
|
|
570
|
+
self._post_q.task_done()
|
|
517
571
|
|
|
518
572
|
def _render_title(self, setup_complete, ob, blocker):
|
|
519
573
|
if blocker:
|
|
@@ -641,9 +695,10 @@ class S4LMenuBar(rumps.App):
|
|
|
641
695
|
rumps.MenuItem("Run draft cycle in Claude", callback=self._draft)
|
|
642
696
|
)
|
|
643
697
|
# No "Post approved drafts" item: approving a review card already posts
|
|
644
|
-
# directly + programmatically (
|
|
645
|
-
# CDP poster). A menu button
|
|
646
|
-
#
|
|
698
|
+
# that card directly + programmatically (_on_card_decision -> queue ->
|
|
699
|
+
# _post_worker_loop -> st.post_drafts -> the CDP poster). A menu button
|
|
700
|
+
# that types a prompt into the chat to do the same thing was a redundant
|
|
701
|
+
# detour through the model, so it's gone.
|
|
647
702
|
return out
|
|
648
703
|
|
|
649
704
|
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/social-autoposter-mcp",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.90",
|
|
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
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Programmatic equivalent of the menu-bar "Please update now" button
|
|
3
|
+
# (mcp/menubar/s4l_menubar.py::_mcpb_update_work). Pulls the latest .mcpb from
|
|
4
|
+
# GitHub releases, unzips it over the Claude Desktop extension dir in place, and
|
|
5
|
+
# restarts Claude so the new MCP server loads. Designed to be run over SSH on a
|
|
6
|
+
# .mcpb box (e.g. `ssh macstadium 'bash -s' < scripts/s4l_box_update.sh`), where
|
|
7
|
+
# npm/npx is absent so the `runtime action:update` (npx) path is dead.
|
|
8
|
+
#
|
|
9
|
+
# Flags:
|
|
10
|
+
# --check Print installed vs latest and exit (no download, no restart).
|
|
11
|
+
# --no-restart Download + unpack the new .mcpb but do NOT restart Claude.
|
|
12
|
+
# (default) Download + unpack + restart Claude.
|
|
13
|
+
#
|
|
14
|
+
# Exits: 0 ok / already current, 2 download failed, 3 unpack failed, 4 no install.
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
|
|
17
|
+
EXT_DIR="$HOME/Library/Application Support/Claude/Claude Extensions/local.mcpb.m13v.social-autoposter"
|
|
18
|
+
MCPB_URL="https://github.com/m13v/social-autoposter/releases/latest/download/social-autoposter.mcpb"
|
|
19
|
+
RELEASE_API="https://api.github.com/repos/m13v/social-autoposter/releases/latest"
|
|
20
|
+
PY="/usr/bin/python3"
|
|
21
|
+
|
|
22
|
+
mode="run"
|
|
23
|
+
case "${1:-}" in
|
|
24
|
+
--check) mode="check" ;;
|
|
25
|
+
--no-restart) mode="no-restart" ;;
|
|
26
|
+
"") mode="run" ;;
|
|
27
|
+
*) echo "unknown flag: $1" >&2; exit 64 ;;
|
|
28
|
+
esac
|
|
29
|
+
|
|
30
|
+
[ -f "$EXT_DIR/manifest.json" ] || { echo "no .mcpb install at $EXT_DIR" >&2; exit 4; }
|
|
31
|
+
|
|
32
|
+
installed="$("$PY" -c "import json,sys;print((json.load(open(sys.argv[1])) or {}).get('version',''))" "$EXT_DIR/manifest.json" 2>/dev/null || true)"
|
|
33
|
+
latest_tag="$(curl -fsSL -m 15 "$RELEASE_API" | "$PY" -c "import sys,json;print((json.load(sys.stdin) or {}).get('tag_name',''))" 2>/dev/null || true)"
|
|
34
|
+
latest="${latest_tag#v}"
|
|
35
|
+
echo "installed=$installed latest=$latest"
|
|
36
|
+
|
|
37
|
+
if [ "$mode" = "check" ]; then
|
|
38
|
+
[ -n "$latest" ] && [ "$installed" != "$latest" ] && echo "update_available=true" || echo "update_available=false"
|
|
39
|
+
exit 0
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
if [ -n "$latest" ] && [ "$installed" = "$latest" ]; then
|
|
43
|
+
echo "already on latest ($installed); re-applying anyway would just restart Claude. skipping."
|
|
44
|
+
# Comment the next line out if you want a forced re-unpack even when current.
|
|
45
|
+
exit 0
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
tmpd="$(mktemp -d -t s4l-update-XXXXXX)"
|
|
49
|
+
trap 'rm -rf "$tmpd"' EXIT
|
|
50
|
+
mcpb="$tmpd/social-autoposter.mcpb"
|
|
51
|
+
|
|
52
|
+
echo "downloading $MCPB_URL ..."
|
|
53
|
+
curl -fLs -m 300 "$MCPB_URL" -o "$mcpb" || { echo "download failed" >&2; exit 2; }
|
|
54
|
+
sz=$(stat -f%z "$mcpb" 2>/dev/null || echo 0)
|
|
55
|
+
[ "$sz" -ge 100000 ] || { echo "download too small ($sz bytes), aborting" >&2; exit 2; }
|
|
56
|
+
|
|
57
|
+
echo "unpacking into extension dir ..."
|
|
58
|
+
unzip -oq "$mcpb" -d "$EXT_DIR" || { echo "unpack failed" >&2; exit 3; }
|
|
59
|
+
new_ver="$("$PY" -c "import json,sys;print((json.load(open(sys.argv[1])) or {}).get('version',''))" "$EXT_DIR/manifest.json" 2>/dev/null || true)"
|
|
60
|
+
echo "unpacked version=$new_ver"
|
|
61
|
+
|
|
62
|
+
if [ "$mode" = "no-restart" ]; then
|
|
63
|
+
echo "done (no restart requested); restart Claude to load v$new_ver."
|
|
64
|
+
exit 0
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
# Restart Claude. From an SSH session we skip the osascript graceful-quit the
|
|
68
|
+
# menu bar uses (it can trip an Automation TCC prompt for sshd and block
|
|
69
|
+
# unattended); killall sends SIGTERM and needs no automation grant.
|
|
70
|
+
echo "restarting Claude ..."
|
|
71
|
+
killall Claude 2>/dev/null || true
|
|
72
|
+
sleep 4
|
|
73
|
+
killall -9 Claude 2>/dev/null || true
|
|
74
|
+
sleep 1
|
|
75
|
+
open -a Claude 2>/dev/null || true
|
|
76
|
+
echo "done; Claude restarting on v$new_ver."
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# s4l-ctl: programmatic control of the running social-autoposter (S4L) plugin via
|
|
3
|
+
# its loopback tool server, for QA. Runs the same handlers as the in-chat MCP
|
|
4
|
+
# tools (POST /tool/<name>). MUST run ON the box (the loopback is 127.0.0.1-only),
|
|
5
|
+
# so over SSH use: ssh macstadium 'bash -s -- <subcommand> [args]' < scripts/s4l_ctl.sh
|
|
6
|
+
#
|
|
7
|
+
# Subcommands:
|
|
8
|
+
# status Dashboard snapshot (read-only).
|
|
9
|
+
# count Number of pending (unposted) drafts (read-only).
|
|
10
|
+
# drafts List pending drafts with their 1-based numbers (read-only).
|
|
11
|
+
# approve <n> [n...] Post the given card numbers. DESTRUCTIVE — requires --yes.
|
|
12
|
+
# approve-all Post EVERY pending card. DESTRUCTIVE — requires --yes.
|
|
13
|
+
#
|
|
14
|
+
# DESTRUCTIVE note: "approve" really posts replies to live X/Twitter threads. The
|
|
15
|
+
# write subcommands refuse to run unless --yes is present (no interactive prompt,
|
|
16
|
+
# because over piped SSH there is no tty). For host-level plugin UPDATE use the
|
|
17
|
+
# separate scripts/s4l_box_update.sh (different mechanism: works even when the
|
|
18
|
+
# loopback is down, and it restarts Claude).
|
|
19
|
+
set -euo pipefail
|
|
20
|
+
|
|
21
|
+
BATCH="review-queue"
|
|
22
|
+
PLAN="/tmp/twitter_cycle_plan_${BATCH}.json"
|
|
23
|
+
EP="$HOME/.social-autoposter-mcp/panel-endpoint.json"
|
|
24
|
+
PY="/usr/bin/python3"
|
|
25
|
+
|
|
26
|
+
YES=0; ARGS=()
|
|
27
|
+
for a in "$@"; do
|
|
28
|
+
case "$a" in --yes|-y) YES=1 ;; *) ARGS+=("$a") ;; esac
|
|
29
|
+
done
|
|
30
|
+
set -- ${ARGS[@]+"${ARGS[@]}"}
|
|
31
|
+
cmd="${1:-}"; [ $# -gt 0 ] && shift || true
|
|
32
|
+
|
|
33
|
+
[ -f "$EP" ] || { echo "no panel-endpoint.json — is Claude Desktop / the MCP running?" >&2; exit 1; }
|
|
34
|
+
URL="$("$PY" -c "import json;print(json.load(open('$EP'))['url'])")"
|
|
35
|
+
curl -s -m 3 "${URL}health" >/dev/null || { echo "loopback unreachable at $URL" >&2; exit 1; }
|
|
36
|
+
|
|
37
|
+
tool() { curl -s -m "${2:-900}" -X POST "${URL}tool/$1" -H 'Content-Type: application/json' -d "${3:-{}}"; }
|
|
38
|
+
|
|
39
|
+
pending_count() {
|
|
40
|
+
[ -f "$PLAN" ] || { echo 0; return; }
|
|
41
|
+
"$PY" -c "import json;d=json.load(open('$PLAN'));print(sum(1 for c in d.get('candidates',[]) if not c.get('posted')))"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
case "$cmd" in
|
|
45
|
+
status)
|
|
46
|
+
tool dashboard 20 ; echo ;;
|
|
47
|
+
count)
|
|
48
|
+
echo "pending=$(pending_count)" ;;
|
|
49
|
+
drafts)
|
|
50
|
+
if [ ! -f "$PLAN" ]; then echo "no review-queue plan on box (0 drafts)"; exit 0; fi
|
|
51
|
+
"$PY" - "$PLAN" <<'PYEOF'
|
|
52
|
+
import json,sys
|
|
53
|
+
d=json.load(open(sys.argv[1]))
|
|
54
|
+
for i,c in enumerate(d.get("candidates",[]),1):
|
|
55
|
+
if c.get("posted"): continue
|
|
56
|
+
txt=(c.get("reply_text") or "").replace("\n"," ")
|
|
57
|
+
print(f"#{i:<4} @{(c.get('thread_author') or '?'):<18} {txt[:90]}")
|
|
58
|
+
PYEOF
|
|
59
|
+
echo "pending=$(pending_count)" ;;
|
|
60
|
+
approve)
|
|
61
|
+
[ $# -ge 1 ] || { echo "usage: approve <n> [n...] --yes" >&2; exit 64; }
|
|
62
|
+
nums="$(printf '%s\n' "$@" | paste -sd, -)"
|
|
63
|
+
if [ "$YES" != "1" ]; then
|
|
64
|
+
echo "REFUSING: 'approve $*' will POST those cards to live X. Re-run with --yes to confirm." >&2; exit 3; fi
|
|
65
|
+
echo "posting cards [$nums] ..."
|
|
66
|
+
tool post_drafts 900 "{\"batch_id\":\"$BATCH\",\"post\":[$nums]}" ; echo ;;
|
|
67
|
+
approve-all)
|
|
68
|
+
n="$(pending_count)"
|
|
69
|
+
if [ "$YES" != "1" ]; then
|
|
70
|
+
echo "REFUSING: approve-all will POST all $n pending cards to live X. Re-run with --yes to confirm." >&2; exit 3; fi
|
|
71
|
+
echo "posting all $n pending cards ..."
|
|
72
|
+
tool post_drafts 1800 "{\"batch_id\":\"$BATCH\",\"post_all\":true}" ; echo ;;
|
|
73
|
+
*)
|
|
74
|
+
echo "usage: s4l_ctl.sh {status|count|drafts|approve <n...>|approve-all} [--yes]" >&2; exit 64 ;;
|
|
75
|
+
esac
|
|
@@ -1209,6 +1209,37 @@ def cmd_connect(args) -> dict:
|
|
|
1209
1209
|
}
|
|
1210
1210
|
|
|
1211
1211
|
|
|
1212
|
+
def cmd_resolve_handle(args) -> dict:
|
|
1213
|
+
"""Read the live logged-in @handle from the managed Chrome and persist it to
|
|
1214
|
+
config.json accounts.twitter.handle.
|
|
1215
|
+
|
|
1216
|
+
The MCP post preflight calls this to self-heal a missing handle — the onboarding
|
|
1217
|
+
gap where connect_x's best-effort live-DOM read silently no-op'd, leaving the
|
|
1218
|
+
install logged in but with accounts:null, so twitter_browser.py refused EVERY
|
|
1219
|
+
reply with no_account_configured. Reading the handle from the SAME session the
|
|
1220
|
+
poster posts through is ground truth, not a guess, so it's safe where a hardcoded
|
|
1221
|
+
fallback would not be. Best-effort: returns state=browser_not_running / no_handle
|
|
1222
|
+
on failure and never raises."""
|
|
1223
|
+
try:
|
|
1224
|
+
ws, send = _attach()
|
|
1225
|
+
except Exception as e:
|
|
1226
|
+
return {"ok": False, "state": "browser_not_running", "error": str(e)}
|
|
1227
|
+
handle = None
|
|
1228
|
+
try:
|
|
1229
|
+
handle = _resolve_live_handle(send)
|
|
1230
|
+
except Exception:
|
|
1231
|
+
handle = None
|
|
1232
|
+
finally:
|
|
1233
|
+
try:
|
|
1234
|
+
ws.close()
|
|
1235
|
+
except Exception:
|
|
1236
|
+
pass
|
|
1237
|
+
if not handle:
|
|
1238
|
+
return {"ok": False, "state": "no_handle"}
|
|
1239
|
+
persisted = _write_handle_to_config(handle)
|
|
1240
|
+
return {"ok": True, "state": "resolved", "handle": handle, "persisted": persisted}
|
|
1241
|
+
|
|
1242
|
+
|
|
1212
1243
|
def main() -> int:
|
|
1213
1244
|
ap = argparse.ArgumentParser(description="Twitter/X session bootstrap for MCP setup.")
|
|
1214
1245
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
@@ -1216,6 +1247,10 @@ def main() -> int:
|
|
|
1216
1247
|
sub.add_parser("detect-sources",
|
|
1217
1248
|
help="List browsers/profiles to import the X session from "
|
|
1218
1249
|
"(JSON, for the panel dropdown). No keychain prompt.")
|
|
1250
|
+
sub.add_parser("resolve-handle",
|
|
1251
|
+
help="Read the live logged-in @handle from the managed Chrome and "
|
|
1252
|
+
"persist it to config.json accounts.twitter.handle. Idempotent "
|
|
1253
|
+
"self-heal for the post preflight; never overwrites a real handle.")
|
|
1219
1254
|
c = sub.add_parser("connect", help="Ensure browser + import/validate the X session.")
|
|
1220
1255
|
c.add_argument("--source", default=None,
|
|
1221
1256
|
help="Browser profile to import from (e.g. chrome:Default, arc:Default), "
|
|
@@ -1244,6 +1279,8 @@ def main() -> int:
|
|
|
1244
1279
|
out = {"ok": False, "state": "error", "error": _WEBSOCKET_IMPORT_ERROR}
|
|
1245
1280
|
elif args.cmd == "status":
|
|
1246
1281
|
out = cmd_status(args)
|
|
1282
|
+
elif args.cmd == "resolve-handle":
|
|
1283
|
+
out = cmd_resolve_handle(args)
|
|
1247
1284
|
else:
|
|
1248
1285
|
out = cmd_connect(args)
|
|
1249
1286
|
print(json.dumps(out, indent=2))
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Headless logic test for per-card serialized posting in the menu bar.
|
|
2
|
+
|
|
3
|
+
Stubs the heavy deps (rumps / sentry_init / s4l_state) so s4l_menubar imports
|
|
4
|
+
without AppKit, then drives the REAL _on_card_decision + _post_worker_loop on an
|
|
5
|
+
instance built via object.__new__ (bypassing rumps.App.__init__). Verifies:
|
|
6
|
+
1. posts run strictly one-at-a-time (no overlap on the shared browser)
|
|
7
|
+
2. order is preserved (FIFO)
|
|
8
|
+
3. plain approvals -> post=[n]; edited approvals -> edits=[{n,text}]
|
|
9
|
+
4. rejected cards never post
|
|
10
|
+
5. _posts_outstanding / _review_active settle to idle once drained
|
|
11
|
+
"""
|
|
12
|
+
import os
|
|
13
|
+
import queue
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import types
|
|
18
|
+
|
|
19
|
+
HERE = os.path.join(os.path.dirname(__file__), "..", "mcp", "menubar")
|
|
20
|
+
sys.path.insert(0, os.path.abspath(HERE))
|
|
21
|
+
|
|
22
|
+
# --- stub heavy deps so the import is headless --------------------------------
|
|
23
|
+
rumps = types.ModuleType("rumps")
|
|
24
|
+
class _App:
|
|
25
|
+
def __init__(self, *a, **k):
|
|
26
|
+
pass
|
|
27
|
+
rumps.App = _App
|
|
28
|
+
rumps.Timer = lambda *a, **k: types.SimpleNamespace(start=lambda: None, stop=lambda: None)
|
|
29
|
+
rumps.MenuItem = lambda *a, **k: object()
|
|
30
|
+
rumps.separator = object()
|
|
31
|
+
rumps.notification = lambda *a, **k: None
|
|
32
|
+
sys.modules["rumps"] = rumps
|
|
33
|
+
|
|
34
|
+
sentry_init = types.ModuleType("sentry_init")
|
|
35
|
+
sentry_init.init_sentry = lambda *a, **k: None
|
|
36
|
+
sentry_init.capture = lambda *a, **k: None
|
|
37
|
+
sys.modules["sentry_init"] = sentry_init
|
|
38
|
+
|
|
39
|
+
# Track concurrency + record every post_drafts call.
|
|
40
|
+
overlap_detected = []
|
|
41
|
+
inflight = {"n": 0}
|
|
42
|
+
inflight_lock = threading.Lock()
|
|
43
|
+
calls = []
|
|
44
|
+
|
|
45
|
+
def fake_post_drafts(batch_id, post=None, edits=None, timeout=900):
|
|
46
|
+
with inflight_lock:
|
|
47
|
+
inflight["n"] += 1
|
|
48
|
+
if inflight["n"] > 1:
|
|
49
|
+
overlap_detected.append(True)
|
|
50
|
+
calls.append({"batch": batch_id, "post": post or [], "edits": edits or []})
|
|
51
|
+
time.sleep(0.15) # simulate a slow post so overlaps would be caught
|
|
52
|
+
with inflight_lock:
|
|
53
|
+
inflight["n"] -= 1
|
|
54
|
+
# mimic the real shape: posted count
|
|
55
|
+
n_posted = len(post or []) + len(edits or [])
|
|
56
|
+
return {"posted": n_posted}
|
|
57
|
+
|
|
58
|
+
st = types.ModuleType("s4l_state")
|
|
59
|
+
st.post_drafts = fake_post_drafts
|
|
60
|
+
st.accessibility_trusted = lambda: True
|
|
61
|
+
st.clear_review_request = lambda: None
|
|
62
|
+
sys.modules["s4l_state"] = st
|
|
63
|
+
|
|
64
|
+
import s4l_menubar # noqa: E402
|
|
65
|
+
|
|
66
|
+
# --- build an instance without running rumps.App.__init__ ---------------------
|
|
67
|
+
app = object.__new__(s4l_menubar.S4LMenuBar)
|
|
68
|
+
app._post_q = queue.Queue()
|
|
69
|
+
app._post_worker = None
|
|
70
|
+
app._review_lock = threading.Lock()
|
|
71
|
+
app._panel_open = True
|
|
72
|
+
app._posts_outstanding = 0
|
|
73
|
+
app._review_active = False
|
|
74
|
+
app._notify = lambda title, msg: None # silence Notification Center
|
|
75
|
+
|
|
76
|
+
BATCH = "review-queue"
|
|
77
|
+
|
|
78
|
+
# Approve a quick burst (as if the user clicked Approve on several cards fast),
|
|
79
|
+
# one edited, plus a rejected card that must NOT post.
|
|
80
|
+
decisions = [
|
|
81
|
+
{"n": 1, "approved": True, "text": "reply one", "edited": False},
|
|
82
|
+
{"n": 2, "approved": True, "text": "edited two", "edited": True},
|
|
83
|
+
{"n": 3, "approved": False, "text": "skip", "edited": False},
|
|
84
|
+
{"n": 4, "approved": True, "text": "reply four", "edited": False},
|
|
85
|
+
]
|
|
86
|
+
for d in decisions:
|
|
87
|
+
app._on_card_decision(BATCH, d)
|
|
88
|
+
time.sleep(0.02) # tight succession -> overlap would happen if not serialized
|
|
89
|
+
|
|
90
|
+
# Panel closes while posts may still be draining.
|
|
91
|
+
app._on_review_closed(BATCH, decisions)
|
|
92
|
+
|
|
93
|
+
# Wait for the queue to drain.
|
|
94
|
+
deadline = time.time() + 10
|
|
95
|
+
while time.time() < deadline:
|
|
96
|
+
with app._review_lock:
|
|
97
|
+
if app._posts_outstanding == 0 and app._post_q.empty():
|
|
98
|
+
break
|
|
99
|
+
time.sleep(0.05)
|
|
100
|
+
|
|
101
|
+
# --- assertions ---------------------------------------------------------------
|
|
102
|
+
fail = []
|
|
103
|
+
if overlap_detected:
|
|
104
|
+
fail.append(f"posts overlapped ({len(overlap_detected)} times) — not serialized")
|
|
105
|
+
posted_ns = [(c["post"], c["edits"]) for c in calls]
|
|
106
|
+
expected = [([1], []), ([], [{"n": 2, "text": "edited two"}]), ([4], [])]
|
|
107
|
+
if posted_ns != expected:
|
|
108
|
+
fail.append(f"wrong calls/order: got {posted_ns}\n expected {expected}")
|
|
109
|
+
if any(c["post"] == [3] or any(e.get("n") == 3 for e in c["edits"]) for c in calls):
|
|
110
|
+
fail.append("rejected card #3 was posted")
|
|
111
|
+
with app._review_lock:
|
|
112
|
+
if app._posts_outstanding != 0:
|
|
113
|
+
fail.append(f"_posts_outstanding leaked: {app._posts_outstanding}")
|
|
114
|
+
if app._review_active:
|
|
115
|
+
fail.append("_review_active stuck true after drain + panel closed")
|
|
116
|
+
|
|
117
|
+
if fail:
|
|
118
|
+
print("FAIL:")
|
|
119
|
+
for f in fail:
|
|
120
|
+
print(" -", f)
|
|
121
|
+
sys.exit(1)
|
|
122
|
+
print("PASS: 3 posts, serialized, FIFO order, #3 skipped, flags settled idle.")
|
|
123
|
+
print(" calls:", posted_ns)
|