social-autoposter 1.6.89 → 1.6.91

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 CHANGED
@@ -23,7 +23,7 @@ import { xStatus, xConnect, xDetectSources, xScanProfile, summarizeXAuth } from
23
23
  import { startProvisioning, isProvisioning, readProgress, runtimeReady, readRuntime, resolvePython, resolveChrome, ensureMenubar, ensurePipelineCurrent, } from "./runtime.js";
24
24
  import { blockOnboardingMilestone, completeOnboardingMilestone, ensureDoctorPhase, onboardingLedger, onboardingSnapshot, recordOnboardingAttempt, runDoctorPhase, } from "./onboarding.js";
25
25
  import { VERSION, versionStatus, latestPublishedVersion } from "./version.js";
26
- import { initSentry, sendHeartbeat, captureError, flushSentry } from "./telemetry.js";
26
+ import { initSentry, sendHeartbeat, captureError, flushSentry, startLogStreaming, flushLogs } from "./telemetry.js";
27
27
  import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, getUiCapability, } from "@modelcontextprotocol/ext-apps/server";
28
28
  import { fileURLToPath } from "node:url";
29
29
  import http from "node:http";
@@ -532,10 +532,64 @@ function renderDraftsTable(plan) {
532
532
  })
533
533
  .join("\n\n");
534
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
+ }
535
571
  async function postApproved(batchId, plan) {
536
572
  const approved = (plan.candidates || []).filter((c) => c.approved === true);
537
573
  if (approved.length === 0)
538
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
+ }
539
593
  // Posting is a priority over scanning: abort any in-flight plugin scan so the
540
594
  // approved post takes the browser immediately instead of waiting on the lock.
541
595
  // Plugin pipeline only — never affects the plist autopilot.
@@ -574,6 +628,17 @@ async function postApproved(batchId, plan) {
574
628
  // BYO-Chrome), else default to the local harness on port 9555.
575
629
  TWITTER_CDP_URL: process.env.TWITTER_CDP_URL || "http://127.0.0.1:9555",
576
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
+ },
577
642
  });
578
643
  }
579
644
  finally {
@@ -644,6 +709,7 @@ async function postApproved(batchId, plan) {
644
709
  });
645
710
  void flushSentry(2000);
646
711
  }
712
+ void flushLogs();
647
713
  return {
648
714
  attempted: approved.length,
649
715
  posted: realPosted,
@@ -2630,6 +2696,11 @@ registerAppResource(server, "S4L product link", PRODUCT_LINK_URI, { mimeType: RE
2630
2696
  }));
2631
2697
  async function main() {
2632
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();
2633
2704
  // A plugin UPDATE refreshes this server (dist/) but not the materialized
2634
2705
  // pipeline. Re-extract the bundled pipeline.tgz when it's newer than what's on
2635
2706
  // disk, BEFORE serving, so the very first scan uses the shipped pipeline (not
@@ -2684,6 +2755,7 @@ async function main() {
2684
2755
  main().catch(async (err) => {
2685
2756
  console.error("[social-autoposter-mcp] fatal:", err);
2686
2757
  captureError(err, { component: "main" });
2758
+ await flushLogs();
2687
2759
  await flushSentry();
2688
2760
  process.exit(1);
2689
2761
  });
@@ -144,7 +144,12 @@ export function flushOnboardingEvents() {
144
144
  error: "installation identity unavailable",
145
145
  };
146
146
  }
147
- const base = (process.env.AUTOPOSTER_API_BASE || "https://s4l.ai").replace(/\/+$/, "");
147
+ // Onboarding milestones go to the CLOUD RUN host (AUTOPOSTER_LOG_BASE,
148
+ // default app.s4l.ai), the same GCP-logging lane as the raw log stream: the
149
+ // relay console.log()s each event so Cloud Run's runtime ships it to Cloud
150
+ // Logging. NOT the Vercel host (AUTOPOSTER_API_BASE / s4l.ai) the heartbeat
151
+ // still uses — these events are not a DB row anymore.
152
+ const base = (process.env.AUTOPOSTER_LOG_BASE || "https://app.s4l.ai").replace(/\/+$/, "");
148
153
  let sent = 0;
149
154
  // Re-read after every batch. This catches milestone events appended while a
150
155
  // prior network request was in flight, so the final onboarding event is not
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) => {
@@ -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
+ }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.89",
3
- "installedAt": "2026-06-23T18:52:53.457Z"
2
+ "version": "1.6.91",
3
+ "installedAt": "2026-06-23T19:56:23.942Z"
4
4
  }
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.89",
5
+ "version": "1.6.91",
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": {
@@ -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 per draft in
4
- the top-right corner: thread context, an EDITABLE reply field, a counter, and
5
- Reject / Approve (+ Reject all). Advancing through every card calls on_complete
6
- with the decisions. The whole AppKit surface is isolated behind that one
7
- function so the menu bar wiring doesn't depend on the windowing details.
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 initWithDrafts_onComplete_(self, drafts, on_complete):
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}. on_complete(decisions)
258
- fires on the main thread when the user finishes or closes the window."""
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().initWithDrafts_onComplete_(drafts, on_complete)
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, lambda decisions: self._on_review_done(batch, decisions)
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 _on_review_done(self, batch, decisions):
487
- # Runs on the main thread (from the card controller). Translate decisions
488
- # into post_drafts args and post on a background thread so the UI stays
489
- # responsive (posting can take minutes).
490
- approved = [d for d in decisions if d.get("approved")]
491
- post_nums = [d["n"] for d in approved if not d.get("edited")]
492
- edits = [{"n": d["n"], "text": d["text"]} for d in approved if d.get("edited")]
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
- threading.Thread(target=work, daemon=True).start()
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 (_on_review_done -> st.post_drafts -> the
645
- # CDP poster). A menu button that types a prompt into the chat to do the
646
- # same thing was a redundant detour through the model, so it's gone.
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.89",
3
+ "version": "1.6.91",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.89",
3
+ "version": "1.6.91",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js"
@@ -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)