social-autoposter 1.6.91 → 1.6.93
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 +264 -52
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +71 -16
- package/mcp/menubar/s4l_state.py +40 -8
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/active_users.py +190 -0
- package/scripts/test_percard_posting.py +21 -2
package/mcp/dist/index.js
CHANGED
|
@@ -235,11 +235,20 @@ const TOOL_ACTIVITY = {
|
|
|
235
235
|
post_drafts: "posting",
|
|
236
236
|
get_stats: "loading stats",
|
|
237
237
|
};
|
|
238
|
+
function toolActivityLabel(name, args) {
|
|
239
|
+
const fallback = TOOL_ACTIVITY[name];
|
|
240
|
+
if (!fallback)
|
|
241
|
+
return null;
|
|
242
|
+
const override = typeof args?.__saps_activity_label === "string"
|
|
243
|
+
? args.__saps_activity_label.replace(/\s+/g, " ").trim().slice(0, 80)
|
|
244
|
+
: "";
|
|
245
|
+
return override || fallback;
|
|
246
|
+
}
|
|
238
247
|
function withActivity(name, cb) {
|
|
239
|
-
|
|
240
|
-
if (!label)
|
|
248
|
+
if (!TOOL_ACTIVITY[name])
|
|
241
249
|
return cb;
|
|
242
250
|
return async (args, extra) => {
|
|
251
|
+
const label = toolActivityLabel(name, args) || TOOL_ACTIVITY[name];
|
|
243
252
|
writeActivity("working", label);
|
|
244
253
|
try {
|
|
245
254
|
return await cb(args, extra);
|
|
@@ -504,9 +513,13 @@ function renderDraftsTable(plan) {
|
|
|
504
513
|
const candidates = plan.candidates || [];
|
|
505
514
|
return candidates
|
|
506
515
|
// Number by FULL-array index (matches post_drafts + the menu bar), then drop
|
|
507
|
-
// already-
|
|
516
|
+
// already-finished entries so the cards only show what's still pending.
|
|
508
517
|
.map((c, i) => ({ c, n: i + 1 }))
|
|
509
|
-
.filter((e) => e.c.posted !== true)
|
|
518
|
+
.filter((e) => e.c.posted !== true && e.c.terminal !== true)
|
|
519
|
+
// The queue is append-only; newest drafts have the highest stable index.
|
|
520
|
+
// Show those first so review starts with likely-live tweets instead of stale
|
|
521
|
+
// low-number drafts that have been sitting around for hours.
|
|
522
|
+
.sort((a, b) => b.n - a.n)
|
|
510
523
|
.map(({ c, n }) => {
|
|
511
524
|
const author = c.thread_author ? `@${c.thread_author}` : "(unknown thread)";
|
|
512
525
|
const style = c.engagement_style ?? "?";
|
|
@@ -532,6 +545,52 @@ function renderDraftsTable(plan) {
|
|
|
532
545
|
})
|
|
533
546
|
.join("\n\n");
|
|
534
547
|
}
|
|
548
|
+
function parsePostCandidateResults(stdout) {
|
|
549
|
+
const byId = new Map();
|
|
550
|
+
const upsert = (candidateId, outcome, reason, ourUrl) => {
|
|
551
|
+
const prev = byId.get(candidateId);
|
|
552
|
+
// A landed post wins over any earlier noisy line for the same candidate.
|
|
553
|
+
if (prev?.outcome === "posted" && outcome !== "posted")
|
|
554
|
+
return;
|
|
555
|
+
byId.set(candidateId, {
|
|
556
|
+
candidate_id: candidateId,
|
|
557
|
+
outcome,
|
|
558
|
+
...(reason ? { reason } : {}),
|
|
559
|
+
...(ourUrl ? { our_url: ourUrl } : {}),
|
|
560
|
+
});
|
|
561
|
+
};
|
|
562
|
+
for (const line of stdout.split("\n")) {
|
|
563
|
+
let m = /\[post\] candidate (\d+) posted as (\S+) \(post_id=/.exec(line);
|
|
564
|
+
if (m) {
|
|
565
|
+
upsert(m[1], "posted", undefined, m[2]);
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
m = /\[post\] candidate (\d+): pre-post dedup hit\b/.exec(line);
|
|
569
|
+
if (m) {
|
|
570
|
+
upsert(m[1], "skipped", "duplicate_thread_pre_post");
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
m = /\[post\] candidate (\d+) reply failed: ([A-Za-z0-9_:-]+)/.exec(line);
|
|
574
|
+
if (m) {
|
|
575
|
+
upsert(m[1], "skipped", m[2]);
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
m = /\[post\] candidate (\d+) reply succeeded but reply_url invalid:/.exec(line);
|
|
579
|
+
if (m) {
|
|
580
|
+
upsert(m[1], "skipped", "no_reply_url_captured");
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
m = /\[post\] candidate (\d+): empty reply_text; skipping/.exec(line);
|
|
584
|
+
if (m) {
|
|
585
|
+
upsert(m[1], "skipped", "empty_reply_text");
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
m = /\[post\] candidate (\d+) crashed:/.exec(line);
|
|
589
|
+
if (m)
|
|
590
|
+
upsert(m[1], "failed", "exception");
|
|
591
|
+
}
|
|
592
|
+
return [...byId.values()];
|
|
593
|
+
}
|
|
535
594
|
// Resolve the configured posting handle the SAME way account_resolver.py does:
|
|
536
595
|
// AUTOPOSTER_TWITTER_HANDLE env first, then config.json accounts.twitter.handle.
|
|
537
596
|
// Returns the bare handle (no @) or null. The post preflight uses it so a missing
|
|
@@ -590,6 +649,12 @@ async function postApproved(batchId, plan) {
|
|
|
590
649
|
"or set accounts.twitter.handle in config.json.",
|
|
591
650
|
};
|
|
592
651
|
}
|
|
652
|
+
// Mark posting active so scan_candidates DEFERS launching any scan for the
|
|
653
|
+
// duration of this batch (+ grace). This is the source-level mutual exclusion
|
|
654
|
+
// that actually fixes the hijack: the autopilot never launches a scan to race
|
|
655
|
+
// the post for the browser. Reset is guaranteed by scheduleShellLockRelease()
|
|
656
|
+
// in the finally below, so an early/failed post can't wedge scanning.
|
|
657
|
+
postingActive = true;
|
|
593
658
|
// Posting is a priority over scanning: abort any in-flight plugin scan so the
|
|
594
659
|
// approved post takes the browser immediately instead of waiting on the lock.
|
|
595
660
|
// Plugin pipeline only — never affects the plist autopilot.
|
|
@@ -642,9 +707,12 @@ async function postApproved(batchId, plan) {
|
|
|
642
707
|
});
|
|
643
708
|
}
|
|
644
709
|
finally {
|
|
645
|
-
// Always
|
|
646
|
-
|
|
647
|
-
|
|
710
|
+
// Always schedule the grace release (even if the lock acquire failed): the
|
|
711
|
+
// timer both frees the lock AND clears postingActive, so scanning resumes
|
|
712
|
+
// SHELL_LOCK_GRACE_MS after the last card. Holding through the grace lets the
|
|
713
|
+
// NEXT approved card reuse one continuous hold (mirrors the plist holding the
|
|
714
|
+
// lock through the whole posting phase, then releasing at the end).
|
|
715
|
+
scheduleShellLockRelease();
|
|
648
716
|
}
|
|
649
717
|
})();
|
|
650
718
|
// Persist the poster's own stdout/stderr to a dated log. Without this the post
|
|
@@ -680,11 +748,57 @@ async function postApproved(batchId, plan) {
|
|
|
680
748
|
: res.code === 0 && !summObj
|
|
681
749
|
? approved.length
|
|
682
750
|
: 0;
|
|
683
|
-
// Mark
|
|
684
|
-
//
|
|
685
|
-
|
|
751
|
+
// Mark candidates according to the poster's per-candidate outcome. This keeps
|
|
752
|
+
// the review queue honest: posted drafts disappear as posted, terminal skips
|
|
753
|
+
// (dedup, deleted tweet, no captured URL) disappear without being counted as
|
|
754
|
+
// posted, and multi-approval batches no longer smear one posted count across
|
|
755
|
+
// every approved draft.
|
|
756
|
+
const resultRowsFromSummary = Array.isArray(summObj?.candidate_results)
|
|
757
|
+
? summObj?.candidate_results
|
|
758
|
+
: [];
|
|
759
|
+
const resultRows = resultRowsFromSummary.length
|
|
760
|
+
? resultRowsFromSummary
|
|
761
|
+
.map((r) => ({
|
|
762
|
+
candidate_id: String(r.candidate_id ?? ""),
|
|
763
|
+
outcome: String(r.outcome || ""),
|
|
764
|
+
reason: typeof r.reason === "string" ? r.reason : undefined,
|
|
765
|
+
our_url: typeof r.our_url === "string" ? r.our_url : undefined,
|
|
766
|
+
}))
|
|
767
|
+
.filter((r) => r.candidate_id && ["posted", "skipped", "failed"].includes(r.outcome))
|
|
768
|
+
: parsePostCandidateResults(res.stdout);
|
|
769
|
+
const approvedById = new Map();
|
|
770
|
+
approved.forEach((c) => {
|
|
771
|
+
if (c.candidate_id !== undefined && c.candidate_id !== null)
|
|
772
|
+
approvedById.set(String(c.candidate_id), c);
|
|
773
|
+
});
|
|
774
|
+
let touchedPlan = false;
|
|
775
|
+
if (resultRows.length) {
|
|
776
|
+
resultRows.forEach((r, idx) => {
|
|
777
|
+
const c = approvedById.get(r.candidate_id) || approved[idx];
|
|
778
|
+
if (!c)
|
|
779
|
+
return;
|
|
780
|
+
if (r.outcome === "posted") {
|
|
781
|
+
c.posted = true;
|
|
782
|
+
c.terminal = false;
|
|
783
|
+
if (r.our_url)
|
|
784
|
+
c.our_url = r.our_url;
|
|
785
|
+
touchedPlan = true;
|
|
786
|
+
}
|
|
787
|
+
else if (r.outcome === "skipped" || r.outcome === "failed") {
|
|
788
|
+
c.terminal = true;
|
|
789
|
+
c.terminal_reason = r.reason || r.outcome;
|
|
790
|
+
touchedPlan = true;
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
else if (realPosted > 0 || (res.code === 0 && !summObj)) {
|
|
795
|
+
// Legacy fallback for older poster output without parseable per-candidate
|
|
796
|
+
// lines. Mark only when we have no finer-grained signal.
|
|
686
797
|
for (const c of approved)
|
|
687
798
|
c.posted = true;
|
|
799
|
+
touchedPlan = true;
|
|
800
|
+
}
|
|
801
|
+
if (touchedPlan) {
|
|
688
802
|
try {
|
|
689
803
|
writePlan(batchId, plan);
|
|
690
804
|
}
|
|
@@ -1984,6 +2098,11 @@ async function runScanCandidates(project, onProgress) {
|
|
|
1984
2098
|
// long-poll doesn't leave it on a generic "working" (or flicker to idle between
|
|
1985
2099
|
// re-calls). Cleared by the handler when the scan resolves.
|
|
1986
2100
|
writeActivity("scanning", project ? `scanning X for ${project}` : "scanning X");
|
|
2101
|
+
// Single-flight: kill any pre-existing run-twitter-cycle.sh (zombies that
|
|
2102
|
+
// survived a prior preempt, or stale waiters parked behind a post) before
|
|
2103
|
+
// launching, so only ONE scan ever exists. The plist gets this from
|
|
2104
|
+
// run-twitter-cycle-singleton.sh; the MCP's direct launch must enforce it here.
|
|
2105
|
+
sigkillAllScans();
|
|
1987
2106
|
const res = await run("bash", ["skill/run-twitter-cycle.sh"], {
|
|
1988
2107
|
env,
|
|
1989
2108
|
timeoutMs: 900_000,
|
|
@@ -2126,68 +2245,150 @@ function rmShellLockDir() {
|
|
|
2126
2245
|
}
|
|
2127
2246
|
}
|
|
2128
2247
|
const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2129
|
-
//
|
|
2130
|
-
//
|
|
2248
|
+
// SIGKILL a scan's WHOLE process tree (the bash + its browser-harness/tee
|
|
2249
|
+
// children). run-twitter-cycle.sh traps SIGTERM/INT/HUP (skill/lock.sh installs
|
|
2250
|
+
// `trap _sa_release_locks ... TERM`), so a SIGTERM runs the cleanup handler and
|
|
2251
|
+
// the script KEEPS GOING — the scan never dies, still drives Chrome, and the next
|
|
2252
|
+
// autopilot tick stacks another on top (the zombie pileup that stale-reclaimed the
|
|
2253
|
+
// lock mid-post). SIGKILL can't be trapped. Kill children first so the harness CDP
|
|
2254
|
+
// driver lets go of Chrome immediately.
|
|
2255
|
+
function sigkillScanTree(pid) {
|
|
2256
|
+
try {
|
|
2257
|
+
const out = execFileSync("pgrep", ["-P", String(pid)], { encoding: "utf-8", timeout: 4000 });
|
|
2258
|
+
for (const cstr of out.split(/\s+/)) {
|
|
2259
|
+
const c = parseInt(cstr, 10);
|
|
2260
|
+
if (Number.isFinite(c) && c > 0) {
|
|
2261
|
+
try {
|
|
2262
|
+
process.kill(c, "SIGKILL");
|
|
2263
|
+
}
|
|
2264
|
+
catch {
|
|
2265
|
+
/* gone */
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
catch {
|
|
2271
|
+
/* no children / pgrep unavailable */
|
|
2272
|
+
}
|
|
2273
|
+
try {
|
|
2274
|
+
process.kill(pid, "SIGKILL");
|
|
2275
|
+
}
|
|
2276
|
+
catch {
|
|
2277
|
+
/* gone */
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
// Single-flight: SIGKILL every run-twitter-cycle.sh on the box before launching a
|
|
2281
|
+
// fresh scan, so a zombie that survived a prior SIGTERM (or a stale waiter parked
|
|
2282
|
+
// behind a post) can never accumulate. Mirrors the plist's run-twitter-cycle-
|
|
2283
|
+
// singleton.sh "one cycle at a time" guarantee, which the MCP's direct launch
|
|
2284
|
+
// bypassed. Best-effort; never throws.
|
|
2285
|
+
function sigkillAllScans() {
|
|
2286
|
+
try {
|
|
2287
|
+
const out = execFileSync("pgrep", ["-f", "skill/run-twitter-cycle.sh"], {
|
|
2288
|
+
encoding: "utf-8",
|
|
2289
|
+
timeout: 4000,
|
|
2290
|
+
});
|
|
2291
|
+
for (const pstr of out.split(/\s+/)) {
|
|
2292
|
+
const p = parseInt(pstr, 10);
|
|
2293
|
+
if (Number.isFinite(p) && p > 0)
|
|
2294
|
+
sigkillScanTree(p);
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
catch {
|
|
2298
|
+
/* none running */
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
// ---- Lock grace-hold: hold the /tmp lock CONTINUOUSLY across per-card posts ----
|
|
2302
|
+
// The plist pipeline acquires the browser lock ONCE and holds it through the whole
|
|
2303
|
+
// posting phase. The MCP posts per approved card (separate post_drafts calls), and
|
|
2304
|
+
// the old code acquired+released the lock PER CARD — leaving a release window
|
|
2305
|
+
// BETWEEN every card that a parked scan stale-reclaimed (the hijack). Instead we
|
|
2306
|
+
// keep the lock and only release it after SHELL_LOCK_GRACE_MS of no posting, so the
|
|
2307
|
+
// hold EXPANDS as more cards get approved and there is never a gap between cards.
|
|
2308
|
+
const SHELL_LOCK_GRACE_MS = Number(process.env.SAPS_POST_LOCK_GRACE_MS) || 60_000;
|
|
2309
|
+
let shellLockReleaseTimer = null;
|
|
2310
|
+
// True from the start of a post batch until SHELL_LOCK_GRACE_MS after the last
|
|
2311
|
+
// card. scan_candidates checks this and DEFERS launching a scan while it's set —
|
|
2312
|
+
// the real fix: posting and scanning are mutually exclusive at the SOURCE (both
|
|
2313
|
+
// are children of THIS one MCP), so we never even launch a scan that would race
|
|
2314
|
+
// the post for the browser lock. Having the post fight scans for the lock (the
|
|
2315
|
+
// prior approach) lost the race because the autopilot relaunches scans faster
|
|
2316
|
+
// than the post can hold the dir. Reset is guaranteed by the grace timer below,
|
|
2317
|
+
// so it can never wedge scanning permanently.
|
|
2318
|
+
let postingActive = false;
|
|
2319
|
+
function cancelScheduledShellLockRelease() {
|
|
2320
|
+
if (shellLockReleaseTimer) {
|
|
2321
|
+
clearTimeout(shellLockReleaseTimer);
|
|
2322
|
+
shellLockReleaseTimer = null;
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
function scheduleShellLockRelease() {
|
|
2326
|
+
cancelScheduledShellLockRelease();
|
|
2327
|
+
shellLockReleaseTimer = setTimeout(() => {
|
|
2328
|
+
shellLockReleaseTimer = null;
|
|
2329
|
+
postingActive = false; // posting drained -> the autopilot may scan again
|
|
2330
|
+
releaseShellBrowserLock();
|
|
2331
|
+
}, SHELL_LOCK_GRACE_MS);
|
|
2332
|
+
}
|
|
2333
|
+
// SIGKILL a live scan holding the shell browser lock so the post takes the browser
|
|
2334
|
+
// at once. Best-effort; only ever targets a run-twitter-cycle.sh.
|
|
2131
2335
|
function preemptScanHoldingBrowser() {
|
|
2132
2336
|
try {
|
|
2133
2337
|
const pid = shellLockHolderPid();
|
|
2134
2338
|
if (pid && pidAlive(pid) && pidIsScan(pid)) {
|
|
2135
|
-
console.error(`[post] preempting cross-process scan holding the twitter-browser lock (pid ${pid})`);
|
|
2136
|
-
|
|
2137
|
-
process.kill(pid, "SIGTERM");
|
|
2138
|
-
}
|
|
2139
|
-
catch {
|
|
2140
|
-
/* already gone */
|
|
2141
|
-
}
|
|
2339
|
+
console.error(`[post] preempting cross-process scan holding the twitter-browser lock (pid ${pid}) — SIGKILL tree`);
|
|
2340
|
+
sigkillScanTree(pid);
|
|
2142
2341
|
}
|
|
2143
2342
|
}
|
|
2144
2343
|
catch {
|
|
2145
2344
|
/* best effort */
|
|
2146
2345
|
}
|
|
2147
2346
|
}
|
|
2148
|
-
// Take the shell browser lock for the batch. Preempts a scan holder
|
|
2149
|
-
// steals from a live non-scan holder (a peer poster) — there
|
|
2150
|
-
// and posting proceeds unguarded (no worse than before).
|
|
2347
|
+
// Take (or extend) the shell browser lock for the batch. Preempts a scan holder
|
|
2348
|
+
// with SIGKILL; never steals from a live non-scan holder (a peer poster) — there
|
|
2349
|
+
// it returns false and posting proceeds unguarded (no worse than before).
|
|
2151
2350
|
async function acquireShellBrowserLock() {
|
|
2351
|
+
// A new post cancels any pending grace-release and EXTENDS the existing hold.
|
|
2352
|
+
cancelScheduledShellLockRelease();
|
|
2353
|
+
// Already ours? Refresh the pid + expiry and keep holding — this is the "expand
|
|
2354
|
+
// the lock as more cards get approved" path: consecutive per-card posts reuse
|
|
2355
|
+
// ONE continuous hold instead of churning the lock, which is what left a window
|
|
2356
|
+
// a parked scan stale-reclaimed between cards.
|
|
2357
|
+
if (shellLockHolderPid() === process.pid) {
|
|
2358
|
+
try {
|
|
2359
|
+
fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "pid"), String(process.pid));
|
|
2360
|
+
fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "expires_at"), String(Math.floor(Date.now() / 1000) + 1800));
|
|
2361
|
+
}
|
|
2362
|
+
catch {
|
|
2363
|
+
/* best effort */
|
|
2364
|
+
}
|
|
2365
|
+
return true;
|
|
2366
|
+
}
|
|
2152
2367
|
for (let attempt = 0; attempt < 8; attempt++) {
|
|
2153
2368
|
try {
|
|
2154
2369
|
fs.mkdirSync(TW_BROWSER_LOCK_DIR); // atomic mutex — only one winner
|
|
2155
|
-
|
|
2156
|
-
|
|
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
|
-
}
|
|
2370
|
+
// Write the pid IMMEDIATELY (sync) so the dir is never observably pid-less.
|
|
2371
|
+
fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "pid"), String(process.pid));
|
|
2372
|
+
fs.writeFileSync(path.join(TW_BROWSER_LOCK_DIR, "expires_at"), String(Math.floor(Date.now() / 1000) + 1800));
|
|
2167
2373
|
console.error(`[post] holding twitter-browser shell lock pid=${process.pid} — scans queue behind the post`);
|
|
2168
2374
|
return true;
|
|
2169
2375
|
}
|
|
2170
2376
|
catch {
|
|
2171
|
-
// Dir exists. Reclaim if the holder is dead; preempt if it's a scan;
|
|
2377
|
+
// Dir exists. Reclaim if the holder is dead; SIGKILL-preempt if it's a scan;
|
|
2172
2378
|
// otherwise (a live peer poster) leave it and post unguarded.
|
|
2173
2379
|
const pid = shellLockHolderPid();
|
|
2174
2380
|
if (!pid || !pidAlive(pid)) {
|
|
2175
2381
|
rmShellLockDir();
|
|
2176
2382
|
}
|
|
2177
2383
|
else if (pidIsScan(pid)) {
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
}
|
|
2181
|
-
catch {
|
|
2182
|
-
/* already gone */
|
|
2183
|
-
}
|
|
2184
|
-
await sleepMs(400);
|
|
2384
|
+
sigkillScanTree(pid); // SIGKILL — scans trap SIGTERM and survive it
|
|
2385
|
+
await sleepMs(300);
|
|
2185
2386
|
rmShellLockDir();
|
|
2186
2387
|
}
|
|
2187
2388
|
else {
|
|
2188
2389
|
return false; // a real peer holds it — don't steal; proceed
|
|
2189
2390
|
}
|
|
2190
|
-
await sleepMs(
|
|
2391
|
+
await sleepMs(200);
|
|
2191
2392
|
}
|
|
2192
2393
|
}
|
|
2193
2394
|
return false;
|
|
@@ -2213,13 +2414,8 @@ function releaseShellBrowserLock() {
|
|
|
2213
2414
|
function preemptScanForPost() {
|
|
2214
2415
|
try {
|
|
2215
2416
|
if (scanChild && scanChild.pid && scanChild.exitCode === null) {
|
|
2216
|
-
console.error(`[post] preempting in-flight plugin scan (pid ${scanChild.pid}) so the approved post takes the browser`);
|
|
2217
|
-
|
|
2218
|
-
scanChild.kill("SIGTERM");
|
|
2219
|
-
}
|
|
2220
|
-
catch {
|
|
2221
|
-
/* already gone */
|
|
2222
|
-
}
|
|
2417
|
+
console.error(`[post] preempting in-flight plugin scan (pid ${scanChild.pid}) so the approved post takes the browser — SIGKILL tree`);
|
|
2418
|
+
sigkillScanTree(scanChild.pid); // SIGKILL — scan bash traps SIGTERM
|
|
2223
2419
|
}
|
|
2224
2420
|
}
|
|
2225
2421
|
catch {
|
|
@@ -2247,6 +2443,14 @@ function scanBusy() {
|
|
|
2247
2443
|
"twitter-cycle slot). This is CONTENTION, not an empty result. Call scan_candidates again in " +
|
|
2248
2444
|
"~20s to retry — do NOT conclude there are no candidates, and do NOT sleep; just re-call.");
|
|
2249
2445
|
}
|
|
2446
|
+
// A post batch is in progress: scanning is deferred so posting has exclusive use
|
|
2447
|
+
// of the one shared browser (mutual exclusion at the source). Not an error.
|
|
2448
|
+
function scanDeferredForPost() {
|
|
2449
|
+
return textContent("A post is in progress on this machine, so scanning is deferred — only one task drives X at a " +
|
|
2450
|
+
"time and posting has priority. This is EXPECTED, not an error or an empty result. Call " +
|
|
2451
|
+
"scan_candidates again shortly; it runs as soon as the current post batch finishes. Do NOT " +
|
|
2452
|
+
"sleep or conclude there are no candidates; just re-call.");
|
|
2453
|
+
}
|
|
2250
2454
|
// Resolve to {done:true,value} the moment `p` settles, or {done:false} after
|
|
2251
2455
|
// `ms` — whichever is first. The job promise keeps running either way, so a
|
|
2252
2456
|
// later poll re-attaches to it.
|
|
@@ -2363,6 +2567,14 @@ tool("scan_candidates", {
|
|
|
2363
2567
|
"re-calling until it returns candidates. Never sleep or use a background wait to bridge the gap.",
|
|
2364
2568
|
inputSchema: { project: z.string().optional() },
|
|
2365
2569
|
}, async (args) => {
|
|
2570
|
+
// MUTUAL EXCLUSION (the real hijack fix): while a post batch is in progress
|
|
2571
|
+
// (or its grace-hold is active), DEFER scanning entirely rather than launching
|
|
2572
|
+
// a scan that races the post for the one shared browser. Both the scan and the
|
|
2573
|
+
// post are children of THIS MCP, so this in-process gate is exact — the
|
|
2574
|
+
// autopilot never even starts a scan to interrupt a post. Posting always wins;
|
|
2575
|
+
// the autopilot just re-calls and runs once the batch drains.
|
|
2576
|
+
if (postingActive)
|
|
2577
|
+
return scanDeferredForPost();
|
|
2366
2578
|
// Long-poll the single in-flight scan job (see the ScanJob registry above).
|
|
2367
2579
|
// A finished-but-unconsumed job: hand back its result and clear the slot so a
|
|
2368
2580
|
// later call starts a fresh scan.
|
|
@@ -2469,7 +2681,7 @@ tool("submit_drafts", {
|
|
|
2469
2681
|
// drafts are appended; a thread already in the queue (by URL) is skipped (one
|
|
2470
2682
|
// draft per thread). Posted entries are KEPT in place so the 1-based card
|
|
2471
2683
|
// numbering stays stable across runs — the menu bar, the chat table, and
|
|
2472
|
-
// post_drafts all index the full array and filter
|
|
2684
|
+
// post_drafts all index the full array and filter finished rows.
|
|
2473
2685
|
const queue = [
|
|
2474
2686
|
...(readPlan(REVIEW_QUEUE_ID)?.candidates ?? []),
|
|
2475
2687
|
];
|
|
@@ -2484,7 +2696,7 @@ tool("submit_drafts", {
|
|
|
2484
2696
|
added++;
|
|
2485
2697
|
}
|
|
2486
2698
|
writePlan(REVIEW_QUEUE_ID, { candidates: queue });
|
|
2487
|
-
const pending = queue.filter((c) => c.posted !== true);
|
|
2699
|
+
const pending = queue.filter((c) => c.posted !== true && c.terminal !== true);
|
|
2488
2700
|
// Drafts queued = the pipeline verified end-to-end without posting. This is the
|
|
2489
2701
|
// onboarding "draft_verified" terminal goal (formerly completed by draft_cycle).
|
|
2490
2702
|
if (added > 0)
|
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.93",
|
|
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": {
|
|
@@ -164,6 +164,8 @@ class S4LMenuBar(rumps.App):
|
|
|
164
164
|
self._review_lock = threading.Lock()
|
|
165
165
|
self._panel_open = False
|
|
166
166
|
self._posts_outstanding = 0
|
|
167
|
+
self._posting_batch_total = 0
|
|
168
|
+
self._posting_batch_done = 0
|
|
167
169
|
self._spin_i = 0
|
|
168
170
|
self._spinner = None # fast rumps.Timer animating the title while busy
|
|
169
171
|
# Reliable self-check of our own Accessibility (TCC) grant — this is the
|
|
@@ -376,13 +378,17 @@ class S4LMenuBar(rumps.App):
|
|
|
376
378
|
act = st.read_activity()
|
|
377
379
|
label = act.get("label") if act else None
|
|
378
380
|
if label:
|
|
381
|
+
# The update arrow must stay visible even while a tool runs, so the
|
|
382
|
+
# "update available" signal is never masked by activity. _tick skips the
|
|
383
|
+
# title repaint while the spinner owns it, so the arrow is injected here.
|
|
384
|
+
head = "S4L ⬆" if self._update_available else "S4L"
|
|
379
385
|
# A "✓" label (e.g. "posted 3/10 ✓") is a momentary confirmation, not
|
|
380
386
|
# ongoing work — show it without the spinner glyph so it reads as done.
|
|
381
387
|
if "✓" in label:
|
|
382
|
-
self.title = f"
|
|
388
|
+
self.title = f"{head} {label}"
|
|
383
389
|
else:
|
|
384
390
|
self._spin_i = (self._spin_i + 1) % len(SPINNER)
|
|
385
|
-
self.title = f"
|
|
391
|
+
self.title = f"{head} {label} {SPINNER[self._spin_i]}"
|
|
386
392
|
return
|
|
387
393
|
try:
|
|
388
394
|
if self._spinner is not None:
|
|
@@ -395,10 +401,12 @@ class S4LMenuBar(rumps.App):
|
|
|
395
401
|
|
|
396
402
|
# ---- tick: read state, set title, (re)build menu ----------------------
|
|
397
403
|
def _tick(self, _):
|
|
398
|
-
#
|
|
399
|
-
#
|
|
400
|
-
|
|
401
|
-
|
|
404
|
+
# The activity spinner owns the TITLE while a tool runs (we don't fight it at
|
|
405
|
+
# 0.12s), but the menu + update indicator must still refresh mid-run —
|
|
406
|
+
# otherwise the "Please update now" item never appears on a box that's always
|
|
407
|
+
# busy (continuous autopilot). So we no longer bail out wholesale when busy;
|
|
408
|
+
# we only skip the title repaint and the review pop-up.
|
|
409
|
+
busy = self._spinner is not None
|
|
402
410
|
snap = st.snapshot()
|
|
403
411
|
ob = snap.get("onboarding") or st.read_onboarding()
|
|
404
412
|
runtime_ready = bool(snap.get("runtime_ready"))
|
|
@@ -414,7 +422,9 @@ class S4LMenuBar(rumps.App):
|
|
|
414
422
|
blocker = (ob or {}).get("current_blocker")
|
|
415
423
|
blocker_code = (blocker or {}).get("code")
|
|
416
424
|
|
|
417
|
-
|
|
425
|
+
# Spinner owns the title while busy; _spin already keeps the ⬆ visible there.
|
|
426
|
+
if not busy:
|
|
427
|
+
self._render_title(setup_complete, ob, blocker)
|
|
418
428
|
|
|
419
429
|
# Blocker notification only on transition into a new blocker.
|
|
420
430
|
if blocker and blocker_code != self._last_blocker_code:
|
|
@@ -431,6 +441,8 @@ class S4LMenuBar(rumps.App):
|
|
|
431
441
|
if ob
|
|
432
442
|
else 0
|
|
433
443
|
)
|
|
444
|
+
# _update_available / _latest_version are in the signature so a freshly
|
|
445
|
+
# detected update rebuilds the menu (adding "Please update now") even mid-run.
|
|
434
446
|
sig = (
|
|
435
447
|
runtime_ready,
|
|
436
448
|
setup_complete,
|
|
@@ -439,6 +451,8 @@ class S4LMenuBar(rumps.App):
|
|
|
439
451
|
bool(snap.get("autopilot_on")),
|
|
440
452
|
snap.get("version"),
|
|
441
453
|
snap.get("update_available"),
|
|
454
|
+
self._update_available,
|
|
455
|
+
self._latest_version,
|
|
442
456
|
snap.get("x_handle"),
|
|
443
457
|
snap.get("projects_ready"),
|
|
444
458
|
snap.get("projects_total"),
|
|
@@ -448,10 +462,37 @@ class S4LMenuBar(rumps.App):
|
|
|
448
462
|
self._build_menu(runtime_ready, setup_complete, ob, blocker, snap)
|
|
449
463
|
|
|
450
464
|
# Draft-review pop-ups: if a draft cycle left a review request, present the
|
|
451
|
-
# cards.
|
|
452
|
-
|
|
465
|
+
# cards. Don't start a review mid-run (the spinner means a tool is active).
|
|
466
|
+
if not busy:
|
|
467
|
+
self._maybe_start_review()
|
|
453
468
|
|
|
454
469
|
# ---- draft review pop-ups ---------------------------------------------
|
|
470
|
+
def _posting_activity_label_locked(self):
|
|
471
|
+
"""Progress for the current menu-bar approval burst.
|
|
472
|
+
|
|
473
|
+
The server receives one post_drafts call per approved card, so its native
|
|
474
|
+
view is always 1/1. The menu bar owns the burst queue and can show the
|
|
475
|
+
useful progress: current approved post / total approved so far.
|
|
476
|
+
"""
|
|
477
|
+
if self._posts_outstanding <= 0:
|
|
478
|
+
return None
|
|
479
|
+
total = max(
|
|
480
|
+
self._posting_batch_total,
|
|
481
|
+
self._posting_batch_done + self._posts_outstanding,
|
|
482
|
+
)
|
|
483
|
+
current = min(total, self._posting_batch_done + 1)
|
|
484
|
+
return f"posting {current}/{total}"
|
|
485
|
+
|
|
486
|
+
def _write_posting_activity_locked(self):
|
|
487
|
+
label = self._posting_activity_label_locked()
|
|
488
|
+
if label:
|
|
489
|
+
st.write_activity("posting", label)
|
|
490
|
+
return label
|
|
491
|
+
|
|
492
|
+
def _reset_posting_progress_locked(self):
|
|
493
|
+
self._posting_batch_total = 0
|
|
494
|
+
self._posting_batch_done = 0
|
|
495
|
+
|
|
455
496
|
def _maybe_start_review(self):
|
|
456
497
|
if self._review_active:
|
|
457
498
|
return
|
|
@@ -478,8 +519,10 @@ class S4LMenuBar(rumps.App):
|
|
|
478
519
|
sig = tuple((d.get("n"), d.get("reply_text") or "") for d in drafts)
|
|
479
520
|
if sig == self._last_review_sig:
|
|
480
521
|
return
|
|
481
|
-
self.
|
|
482
|
-
|
|
522
|
+
with self._review_lock:
|
|
523
|
+
self._reset_posting_progress_locked()
|
|
524
|
+
self._review_active = True
|
|
525
|
+
self._panel_open = True
|
|
483
526
|
try:
|
|
484
527
|
import s4l_card
|
|
485
528
|
|
|
@@ -508,7 +551,9 @@ class S4LMenuBar(rumps.App):
|
|
|
508
551
|
return
|
|
509
552
|
with self._review_lock:
|
|
510
553
|
self._posts_outstanding += 1
|
|
554
|
+
self._posting_batch_total += 1
|
|
511
555
|
self._review_active = True
|
|
556
|
+
self._write_posting_activity_locked()
|
|
512
557
|
self._post_q.put((batch, decision))
|
|
513
558
|
self._ensure_post_worker()
|
|
514
559
|
|
|
@@ -521,6 +566,7 @@ class S4LMenuBar(rumps.App):
|
|
|
521
566
|
self._panel_open = False
|
|
522
567
|
if self._posts_outstanding <= 0:
|
|
523
568
|
self._review_active = False
|
|
569
|
+
self._reset_posting_progress_locked()
|
|
524
570
|
st.clear_review_request()
|
|
525
571
|
if not any(d.get("approved") for d in decisions):
|
|
526
572
|
self._notify("S4L", "No drafts approved — nothing posted.")
|
|
@@ -535,19 +581,24 @@ class S4LMenuBar(rumps.App):
|
|
|
535
581
|
|
|
536
582
|
def _post_worker_loop(self):
|
|
537
583
|
# Serialized poster: one approved card at a time so two posts never drive
|
|
538
|
-
# the shared harness Chrome simultaneously. The
|
|
539
|
-
#
|
|
584
|
+
# the shared harness Chrome simultaneously. The menu bar passes a burst
|
|
585
|
+
# progress label into post_drafts, so the spinner shows e.g. "posting 17/95"
|
|
586
|
+
# even though each server call is still one approved draft.
|
|
540
587
|
while True:
|
|
541
588
|
batch, decision = self._post_q.get() # blocks until a card is approved
|
|
542
589
|
n = decision.get("n")
|
|
543
590
|
try:
|
|
544
591
|
self._notify("S4L", f"Posting draft {n}…")
|
|
592
|
+
with self._review_lock:
|
|
593
|
+
activity_label = self._posting_activity_label_locked()
|
|
545
594
|
if decision.get("edited"):
|
|
546
595
|
res = st.post_drafts(
|
|
547
|
-
batch,
|
|
596
|
+
batch,
|
|
597
|
+
edits=[{"n": n, "text": decision.get("text") or ""}],
|
|
598
|
+
activity_label=activity_label,
|
|
548
599
|
)
|
|
549
600
|
else:
|
|
550
|
-
res = st.post_drafts(batch, post=[n])
|
|
601
|
+
res = st.post_drafts(batch, post=[n], activity_label=activity_label)
|
|
551
602
|
if res is None:
|
|
552
603
|
self._notify(
|
|
553
604
|
"S4L", "Couldn't post — open Claude Desktop and try the draft again."
|
|
@@ -564,9 +615,13 @@ class S4LMenuBar(rumps.App):
|
|
|
564
615
|
_capture(e, phase="post_card")
|
|
565
616
|
finally:
|
|
566
617
|
with self._review_lock:
|
|
618
|
+
self._posting_batch_done += 1
|
|
567
619
|
self._posts_outstanding -= 1
|
|
568
|
-
if self._posts_outstanding
|
|
620
|
+
if self._posts_outstanding > 0:
|
|
621
|
+
self._write_posting_activity_locked()
|
|
622
|
+
elif not self._panel_open:
|
|
569
623
|
self._review_active = False
|
|
624
|
+
self._reset_posting_progress_locked()
|
|
570
625
|
self._post_q.task_done()
|
|
571
626
|
|
|
572
627
|
def _render_title(self, setup_complete, ob, blocker):
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -299,6 +299,36 @@ def read_activity():
|
|
|
299
299
|
return read_json("activity.json")
|
|
300
300
|
|
|
301
301
|
|
|
302
|
+
def write_activity(state: str, label: str):
|
|
303
|
+
"""Best-effort local activity update. The MCP server normally owns this file,
|
|
304
|
+
but the menu-bar posting queue knows the whole approved-card burst while the
|
|
305
|
+
server only sees one post_drafts call at a time."""
|
|
306
|
+
try:
|
|
307
|
+
p = Path(state_dir()) / "activity.json"
|
|
308
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
309
|
+
p.write_text(
|
|
310
|
+
json.dumps(
|
|
311
|
+
{
|
|
312
|
+
"state": state,
|
|
313
|
+
"label": label,
|
|
314
|
+
"since": time_iso(),
|
|
315
|
+
}
|
|
316
|
+
)
|
|
317
|
+
+ "\n"
|
|
318
|
+
)
|
|
319
|
+
except Exception:
|
|
320
|
+
pass
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def time_iso():
|
|
324
|
+
try:
|
|
325
|
+
import datetime
|
|
326
|
+
|
|
327
|
+
return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
328
|
+
except Exception:
|
|
329
|
+
return ""
|
|
330
|
+
|
|
331
|
+
|
|
302
332
|
def read_review_request():
|
|
303
333
|
return read_json("review-request.json")
|
|
304
334
|
|
|
@@ -320,10 +350,10 @@ def read_plan(plan_path):
|
|
|
320
350
|
|
|
321
351
|
|
|
322
352
|
def review_drafts(plan):
|
|
323
|
-
"""Flatten a plan into the card model: only candidates
|
|
353
|
+
"""Flatten a plan into the card model: only unfinished candidates."""
|
|
324
354
|
out = []
|
|
325
355
|
for i, c in enumerate(((plan or {}).get("candidates") or [])):
|
|
326
|
-
if c.get("posted") is True:
|
|
356
|
+
if c.get("posted") is True or c.get("terminal") is True:
|
|
327
357
|
continue
|
|
328
358
|
out.append(
|
|
329
359
|
{
|
|
@@ -334,15 +364,17 @@ def review_drafts(plan):
|
|
|
334
364
|
"link_url": c.get("link_url"),
|
|
335
365
|
}
|
|
336
366
|
)
|
|
367
|
+
# The review queue is append-only, so the highest stable index is newest and
|
|
368
|
+
# most likely to still be live on X.
|
|
369
|
+
out.sort(key=lambda d: d["n"], reverse=True)
|
|
337
370
|
return out
|
|
338
371
|
|
|
339
372
|
|
|
340
|
-
def post_drafts(batch_id, post=None, edits=None, timeout=900):
|
|
373
|
+
def post_drafts(batch_id, post=None, edits=None, timeout=900, activity_label=None):
|
|
341
374
|
"""Post approved drafts via the loopback tool. `post` = 1-based numbers to
|
|
342
375
|
post as-is; `edits` = [{n, text}] to rewrite then post. Returns the parsed
|
|
343
376
|
result, or None if the loopback is unreachable (Claude Desktop closed)."""
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
)
|
|
377
|
+
args = {"batch_id": batch_id, "post": post or [], "edits": edits or []}
|
|
378
|
+
if activity_label:
|
|
379
|
+
args["__saps_activity_label"] = activity_label
|
|
380
|
+
return loopback_tool("post_drafts", args, timeout=timeout)
|
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.93",
|
|
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,190 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Who is actually using social-autoposter right now.
|
|
3
|
+
|
|
4
|
+
Reads the `installations` heartbeat table (the only live per-install signal) and
|
|
5
|
+
answers "how many real, external people are active" without the inflation that a
|
|
6
|
+
raw install count carries:
|
|
7
|
+
|
|
8
|
+
* install_id is per identity.json, NOT per machine. A reinstall / reset / each
|
|
9
|
+
ephemeral mk0r E2B sandbox mints a fresh id. So we dedupe by `hardware_uuid`
|
|
10
|
+
(the stable per-machine key) and report MACHINES, not install rows.
|
|
11
|
+
* Our own infra (i@m13v.com operator Mac, the agent@mk0r.com / e2b.local VM
|
|
12
|
+
fleet) is filtered out by default so the roster is real customers. Pass
|
|
13
|
+
--all to include it.
|
|
14
|
+
* Cross-references the `posts` table so you can see the alive-but-not-posting
|
|
15
|
+
gap (the blind spot the Cloud Logging stream exists to explain).
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
python3 scripts/active_users.py # external machines, last 7d
|
|
19
|
+
python3 scripts/active_users.py --days 30 # different window
|
|
20
|
+
python3 scripts/active_users.py --all # include our own infra
|
|
21
|
+
python3 scripts/active_users.py --json # machine-readable
|
|
22
|
+
|
|
23
|
+
Operator-local only: uses the direct-Postgres lane via scripts/db.py (absent in
|
|
24
|
+
the shipped npm package), reading DATABASE_URL from ~/social-autoposter/.env.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
from urllib.parse import unquote
|
|
32
|
+
|
|
33
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
34
|
+
from db import load_env, get_conn # noqa: E402
|
|
35
|
+
|
|
36
|
+
# Our own installs, hidden by default so the roster is real external users.
|
|
37
|
+
INTERNAL_EMAILS = {"i@m13v.com", "agent@mk0r.com", "matt@mediar.ai"}
|
|
38
|
+
INTERNAL_HOSTNAME_SUBSTR = ("e2b.local", "71522") # mk0r E2B sandboxes; MacStadium QA box
|
|
39
|
+
# MacStadium remote QA box (hostname "71522", no git_email). It actively runs the
|
|
40
|
+
# pipeline and posts, so without this it masquerades as our only posting customer.
|
|
41
|
+
INTERNAL_HARDWARE_UUIDS = {"07CB793D-6E32-5EF8-82E2-7CDEABD47FBC"}
|
|
42
|
+
|
|
43
|
+
# Connected X handle resolves only from posts.our_account; drop scaffolding values.
|
|
44
|
+
PLACEHOLDER_HANDLES = {"your-twitter-handle", "your_handle", "your-handle", "none", "null", ""}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_handles(raw):
|
|
48
|
+
out = []
|
|
49
|
+
for h in (raw or "").split(","):
|
|
50
|
+
h = h.strip().lstrip("@")
|
|
51
|
+
if h and h.lower() not in PLACEHOLDER_HANDLES and h not in out:
|
|
52
|
+
out.append(h)
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_internal(emails, hostnames, hardware_uuids):
|
|
57
|
+
if any((e or "").lower() in INTERNAL_EMAILS for e in emails):
|
|
58
|
+
return True
|
|
59
|
+
if any(sub in (h or "") for h in hostnames for sub in INTERNAL_HOSTNAME_SUBSTR):
|
|
60
|
+
return True
|
|
61
|
+
if any((u or "") in INTERNAL_HARDWARE_UUIDS for u in hardware_uuids):
|
|
62
|
+
return True
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def fetch(days):
|
|
67
|
+
# One row per MACHINE (hardware_uuid; fall back to a per-install key when the
|
|
68
|
+
# client never reported a hardware_uuid so those installs aren't all merged).
|
|
69
|
+
# `days` is an argparse int (injection-safe), inlined because the wrapper's
|
|
70
|
+
# SQL translation mangles %s placeholders.
|
|
71
|
+
days = int(days)
|
|
72
|
+
q = f"""
|
|
73
|
+
WITH win AS (
|
|
74
|
+
SELECT *,
|
|
75
|
+
COALESCE(NULLIF(git_email, ''), NULLIF(hardware_uuid, ''),
|
|
76
|
+
'anon:' || install_id::text) AS entity_key
|
|
77
|
+
FROM installations
|
|
78
|
+
WHERE last_seen_at > now() - interval '{days} days'
|
|
79
|
+
),
|
|
80
|
+
posted AS (
|
|
81
|
+
SELECT install_id, count(*) AS n
|
|
82
|
+
FROM posts
|
|
83
|
+
WHERE posted_at > now() - interval '{days} days' AND install_id IS NOT NULL
|
|
84
|
+
GROUP BY install_id
|
|
85
|
+
),
|
|
86
|
+
handles AS (
|
|
87
|
+
-- The connected X handle is NOT in the heartbeat; it only reaches the
|
|
88
|
+
-- central DB via posts.our_account, so it exists ONLY for installs that
|
|
89
|
+
-- ever posted (all-time, not windowed: a handle is identity, not activity).
|
|
90
|
+
SELECT install_id, string_agg(DISTINCT our_account, ',') AS hs
|
|
91
|
+
FROM posts
|
|
92
|
+
WHERE our_account IS NOT NULL AND length(trim(our_account)) > 0
|
|
93
|
+
GROUP BY install_id
|
|
94
|
+
)
|
|
95
|
+
SELECT
|
|
96
|
+
w.entity_key,
|
|
97
|
+
count(DISTINCT w.install_id) AS installs,
|
|
98
|
+
count(DISTINCT w.hardware_uuid) AS machines,
|
|
99
|
+
array_remove(array_agg(DISTINCT w.hardware_uuid), NULL) AS hardware_uuids,
|
|
100
|
+
array_remove(array_agg(DISTINCT NULLIF(w.git_email, '')), NULL) AS emails,
|
|
101
|
+
array_remove(array_agg(DISTINCT w.hostname), NULL) AS hostnames,
|
|
102
|
+
string_agg(DISTINCT h.hs, ',') AS handles_raw,
|
|
103
|
+
max(w.os_version) AS os,
|
|
104
|
+
array_remove(array_agg(DISTINCT
|
|
105
|
+
w.last_country || '/' || COALESCE(w.last_city, '-')), NULL) AS locations,
|
|
106
|
+
max(w.last_seen_at) AS last_seen,
|
|
107
|
+
COALESCE(sum(p.n), 0) AS posts
|
|
108
|
+
FROM win w
|
|
109
|
+
LEFT JOIN posted p ON p.install_id = w.install_id
|
|
110
|
+
LEFT JOIN handles h ON h.install_id = w.install_id
|
|
111
|
+
GROUP BY w.entity_key
|
|
112
|
+
ORDER BY last_seen DESC;
|
|
113
|
+
"""
|
|
114
|
+
conn = get_conn()
|
|
115
|
+
try:
|
|
116
|
+
cur = conn.execute(q)
|
|
117
|
+
cols = [c.name for c in cur.description]
|
|
118
|
+
return [dict(zip(cols, row)) for row in cur.fetchall()]
|
|
119
|
+
finally:
|
|
120
|
+
conn.close()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def person(row):
|
|
124
|
+
if row["emails"]:
|
|
125
|
+
return row["emails"][0]
|
|
126
|
+
if row["hostnames"]:
|
|
127
|
+
return row["hostnames"][0]
|
|
128
|
+
return row["entity_key"][:12]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def loc(row):
|
|
132
|
+
return ", ".join(unquote(x) for x in (row["locations"] or [])) or "?"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def main():
|
|
136
|
+
ap = argparse.ArgumentParser(
|
|
137
|
+
description="Active social-autoposter users, deduped per person (email, else machine).")
|
|
138
|
+
ap.add_argument("--days", type=int, default=7, help="lookback window in days (default 7)")
|
|
139
|
+
ap.add_argument("--all", action="store_true", help="include our own infra (i@m13v / mk0r)")
|
|
140
|
+
ap.add_argument("--json", action="store_true", help="emit JSON")
|
|
141
|
+
args = ap.parse_args()
|
|
142
|
+
|
|
143
|
+
load_env()
|
|
144
|
+
rows = fetch(args.days)
|
|
145
|
+
for r in rows:
|
|
146
|
+
r["internal"] = is_internal(r["emails"], r["hostnames"], r["hardware_uuids"])
|
|
147
|
+
r["handles"] = parse_handles(r.get("handles_raw"))
|
|
148
|
+
|
|
149
|
+
external = [r for r in rows if not r["internal"]]
|
|
150
|
+
internal = [r for r in rows if r["internal"]]
|
|
151
|
+
shown = rows if args.all else external
|
|
152
|
+
|
|
153
|
+
if args.json:
|
|
154
|
+
out = [{
|
|
155
|
+
"person": person(r), "x_handles": r["handles"], "machines": r["machines"],
|
|
156
|
+
"installs": r["installs"], "hostnames": r["hostnames"], "emails": r["emails"],
|
|
157
|
+
"os": r["os"], "location": loc(r), "posts": int(r["posts"]),
|
|
158
|
+
"last_seen": r["last_seen"].isoformat() if r["last_seen"] else None,
|
|
159
|
+
"internal": r["internal"],
|
|
160
|
+
} for r in shown]
|
|
161
|
+
print(json.dumps({
|
|
162
|
+
"window_days": args.days,
|
|
163
|
+
"external_machines": len(external),
|
|
164
|
+
"external_people": len({e for r in external for e in r["emails"]}),
|
|
165
|
+
"internal_machines_hidden": 0 if args.all else len(internal),
|
|
166
|
+
"rows": out,
|
|
167
|
+
}, indent=2))
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
people = len({e for r in external for e in r["emails"]})
|
|
171
|
+
print(f"\nActive in last {args.days}d: {len(external)} external machines "
|
|
172
|
+
f"(~{people} identified people){'' if args.all else f', {len(internal)} internal hidden'}\n")
|
|
173
|
+
hdr = (f"{'PERSON':<30} {'X HANDLE':<16} {'HOST':<22} {'OS':<7} {'LOC':<16} "
|
|
174
|
+
f"{'INST':>4} {'POSTS':>6} LAST SEEN")
|
|
175
|
+
print(hdr)
|
|
176
|
+
print("-" * len(hdr))
|
|
177
|
+
for r in shown:
|
|
178
|
+
tag = " [internal]" if r["internal"] else ""
|
|
179
|
+
host = (r["hostnames"][0] if r["hostnames"] else "?")[:22]
|
|
180
|
+
handle = (", ".join(r["handles"]) or "-")[:16]
|
|
181
|
+
print(f"{person(r)[:30]:<30} {handle:<16} {host:<22} {(r['os'] or '?'):<7} "
|
|
182
|
+
f"{loc(r)[:16]:<16} {r['installs']:>4} {int(r['posts']):>6} "
|
|
183
|
+
f"{r['last_seen']:%Y-%m-%d %H:%M}{tag}")
|
|
184
|
+
posting = sum(1 for r in external if r["posts"] > 0)
|
|
185
|
+
print(f"\n of {len(external)} external machines, {posting} posted in the window, "
|
|
186
|
+
f"{len(external) - posting} are alive-but-not-posting.\n")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
main()
|
|
@@ -8,6 +8,7 @@ instance built via object.__new__ (bypassing rumps.App.__init__). Verifies:
|
|
|
8
8
|
3. plain approvals -> post=[n]; edited approvals -> edits=[{n,text}]
|
|
9
9
|
4. rejected cards never post
|
|
10
10
|
5. _posts_outstanding / _review_active settle to idle once drained
|
|
11
|
+
6. activity progress reflects the approved burst total, not each 1-item call
|
|
11
12
|
"""
|
|
12
13
|
import os
|
|
13
14
|
import queue
|
|
@@ -41,13 +42,21 @@ overlap_detected = []
|
|
|
41
42
|
inflight = {"n": 0}
|
|
42
43
|
inflight_lock = threading.Lock()
|
|
43
44
|
calls = []
|
|
45
|
+
activity_events = []
|
|
44
46
|
|
|
45
|
-
def fake_post_drafts(batch_id, post=None, edits=None, timeout=900):
|
|
47
|
+
def fake_post_drafts(batch_id, post=None, edits=None, timeout=900, activity_label=None):
|
|
46
48
|
with inflight_lock:
|
|
47
49
|
inflight["n"] += 1
|
|
48
50
|
if inflight["n"] > 1:
|
|
49
51
|
overlap_detected.append(True)
|
|
50
|
-
calls.append(
|
|
52
|
+
calls.append(
|
|
53
|
+
{
|
|
54
|
+
"batch": batch_id,
|
|
55
|
+
"post": post or [],
|
|
56
|
+
"edits": edits or [],
|
|
57
|
+
"activity_label": activity_label,
|
|
58
|
+
}
|
|
59
|
+
)
|
|
51
60
|
time.sleep(0.15) # simulate a slow post so overlaps would be caught
|
|
52
61
|
with inflight_lock:
|
|
53
62
|
inflight["n"] -= 1
|
|
@@ -57,6 +66,7 @@ def fake_post_drafts(batch_id, post=None, edits=None, timeout=900):
|
|
|
57
66
|
|
|
58
67
|
st = types.ModuleType("s4l_state")
|
|
59
68
|
st.post_drafts = fake_post_drafts
|
|
69
|
+
st.write_activity = lambda state, label: activity_events.append((state, label))
|
|
60
70
|
st.accessibility_trusted = lambda: True
|
|
61
71
|
st.clear_review_request = lambda: None
|
|
62
72
|
sys.modules["s4l_state"] = st
|
|
@@ -70,6 +80,8 @@ app._post_worker = None
|
|
|
70
80
|
app._review_lock = threading.Lock()
|
|
71
81
|
app._panel_open = True
|
|
72
82
|
app._posts_outstanding = 0
|
|
83
|
+
app._posting_batch_total = 0
|
|
84
|
+
app._posting_batch_done = 0
|
|
73
85
|
app._review_active = False
|
|
74
86
|
app._notify = lambda title, msg: None # silence Notification Center
|
|
75
87
|
|
|
@@ -106,6 +118,13 @@ posted_ns = [(c["post"], c["edits"]) for c in calls]
|
|
|
106
118
|
expected = [([1], []), ([], [{"n": 2, "text": "edited two"}]), ([4], [])]
|
|
107
119
|
if posted_ns != expected:
|
|
108
120
|
fail.append(f"wrong calls/order: got {posted_ns}\n expected {expected}")
|
|
121
|
+
labels = [c["activity_label"] for c in calls if c.get("activity_label")]
|
|
122
|
+
if not any(label == "posting 2/3" for label in labels):
|
|
123
|
+
fail.append(f"second post did not carry burst progress 2/3: labels={labels}")
|
|
124
|
+
if not any(label == "posting 3/3" for label in labels):
|
|
125
|
+
fail.append(f"third post did not carry burst progress 3/3: labels={labels}")
|
|
126
|
+
if not any(event == ("posting", "posting 1/3") for event in activity_events):
|
|
127
|
+
fail.append(f"activity never expanded first post to 1/3: events={activity_events}")
|
|
109
128
|
if any(c["post"] == [3] or any(e.get("n") == 3 for e in c["edits"]) for c in calls):
|
|
110
129
|
fail.append("rejected card #3 was posted")
|
|
111
130
|
with app._review_lock:
|