@m13v/s4l 1.6.197-rc.10
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/README.md +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1336 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +513 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- package/skill/topics.sh +51 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
sweep_post_link_clicks.py — behavioral bot-flagger for short-link click logs.
|
|
4
|
+
|
|
5
|
+
Runs in addition to the per-hit UA regex in @m13v/seo-components. The UA
|
|
6
|
+
regex catches obvious crawlers; this sweep catches everything that looks
|
|
7
|
+
human in isolation but stops looking human when you correlate hits across
|
|
8
|
+
ip_hash + code + post + time.
|
|
9
|
+
|
|
10
|
+
All five rules + the R2 per-post excess loop + the counter rebuild now run
|
|
11
|
+
server-side in POST /api/v1/post-links/clicks-sweep (HTTP-only, 2026-06-01;
|
|
12
|
+
no DATABASE_URL on the operator box). This script is a thin trigger that
|
|
13
|
+
POSTs the flags and prints the returned before/flips/after/counter numbers.
|
|
14
|
+
|
|
15
|
+
Rules (all idempotent — re-running won't double-flag):
|
|
16
|
+
|
|
17
|
+
Tier 1 (zero false positives):
|
|
18
|
+
R1 same ip_hash + same code + >=3 hits in a 240s sliding window
|
|
19
|
+
R2 clicks on a post exceed views * platform_ctr_ceiling
|
|
20
|
+
R3 same ip_hash hits >=5 different codes within the window
|
|
21
|
+
|
|
22
|
+
Tier 2 (very low false positives, applied after Tier 1):
|
|
23
|
+
R4 no referrer + browser-looking UA + ip_hash co-occurs with bot rows
|
|
24
|
+
R5 same ip_hash hits >=4 different codes within any 60-second window
|
|
25
|
+
|
|
26
|
+
Each flipped row records the rule in `bot_reason` so we can audit and roll
|
|
27
|
+
back per-rule if a false positive shows up. After flipping, the counter
|
|
28
|
+
post_links.clicks is rebuilt from the per-hit log so the dashboard matches.
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
scripts/sweep_post_link_clicks.py [--dry-run] [--lookback-hours N]
|
|
32
|
+
[--rules R1,R2,R3,R4,R5]
|
|
33
|
+
[--cron] [--rebuild-counter]
|
|
34
|
+
|
|
35
|
+
--lookback-hours N only consider clicks newer than N hours (default 720
|
|
36
|
+
on first/manual run, 6 in --cron mode)
|
|
37
|
+
--cron quick-sweep mode: 6h lookback, decrement-only counter
|
|
38
|
+
--rebuild-counter full SUM(NOT is_bot) rebuild of post_links.clicks
|
|
39
|
+
|
|
40
|
+
Idempotent: only flips rows where is_bot=false today; never un-flips.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import argparse
|
|
46
|
+
import os
|
|
47
|
+
import sys
|
|
48
|
+
|
|
49
|
+
REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
50
|
+
sys.path.insert(0, os.path.join(REPO_DIR, "scripts"))
|
|
51
|
+
|
|
52
|
+
from http_api import api_post, load_env # noqa: E402
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main():
|
|
56
|
+
ap = argparse.ArgumentParser()
|
|
57
|
+
ap.add_argument("--dry-run", action="store_true")
|
|
58
|
+
ap.add_argument("--lookback-hours", type=int, default=None)
|
|
59
|
+
ap.add_argument("--cron", action="store_true",
|
|
60
|
+
help="quick-sweep mode: 6h lookback, decrement-only counter update")
|
|
61
|
+
ap.add_argument("--rebuild-counter", action="store_true",
|
|
62
|
+
help="full counter rebuild from is_bot=false rows (safe, idempotent)")
|
|
63
|
+
ap.add_argument("--rules", default="R1,R2,R3,R4,R5",
|
|
64
|
+
help="comma-separated rule list, default all five")
|
|
65
|
+
args = ap.parse_args()
|
|
66
|
+
|
|
67
|
+
load_env()
|
|
68
|
+
|
|
69
|
+
rules = [r.strip().upper() for r in args.rules.split(",") if r.strip()]
|
|
70
|
+
body = {
|
|
71
|
+
"dry_run": args.dry_run,
|
|
72
|
+
"cron": args.cron,
|
|
73
|
+
"rebuild_counter": args.rebuild_counter,
|
|
74
|
+
"rules": rules,
|
|
75
|
+
}
|
|
76
|
+
if args.lookback_hours is not None:
|
|
77
|
+
body["lookback_hours"] = int(args.lookback_hours)
|
|
78
|
+
|
|
79
|
+
resp = api_post("/api/v1/post-links/clicks-sweep", body)
|
|
80
|
+
data = resp.get("data") or {}
|
|
81
|
+
|
|
82
|
+
window = data.get("window_hours")
|
|
83
|
+
before = data.get("before") or {}
|
|
84
|
+
after = data.get("after") or {}
|
|
85
|
+
flips = data.get("flips") or {}
|
|
86
|
+
counter = data.get("counter") or {}
|
|
87
|
+
|
|
88
|
+
print(f"[before] window={window}h humans={before.get('humans')} "
|
|
89
|
+
f"bots={before.get('bots')} total={before.get('total')}", flush=True)
|
|
90
|
+
print("[flips]", " ".join(f"{k}={flips[k]}" for k in sorted(flips)), flush=True)
|
|
91
|
+
print(f"[after] window={window}h humans={after.get('humans')} "
|
|
92
|
+
f"bots={after.get('bots')} total={after.get('total')}", flush=True)
|
|
93
|
+
|
|
94
|
+
mode = counter.get("mode")
|
|
95
|
+
if mode == "dry-run":
|
|
96
|
+
print(f"[counter] dry-run: would change SUM by ~{counter.get('would_change_sum')}; "
|
|
97
|
+
f"humans-total now {counter.get('humans_total')}", flush=True)
|
|
98
|
+
elif mode == "cron":
|
|
99
|
+
print(f"[counter] cron-mode: rebuilt counters for codes touching "
|
|
100
|
+
f"{counter.get('flagged_rows_touched')} flagged rows", flush=True)
|
|
101
|
+
elif mode == "full-rebuild":
|
|
102
|
+
print(f"[counter] full rebuild done; SUM(post_links.clicks) now = "
|
|
103
|
+
f"{counter.get('sum_after')}", flush=True)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
main()
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Mirror posted Instagram rows from media_posts -> posts.
|
|
3
|
+
|
|
4
|
+
media_posts holds the IG-only fields (video_path, post_type, target_account,
|
|
5
|
+
overlays, source_clips, composition_id). posts holds the platform-agnostic
|
|
6
|
+
fields the dashboard reads (platform, our_url, our_content, our_account,
|
|
7
|
+
posted_at, upvotes, comments_count, views, engagement_updated_at).
|
|
8
|
+
|
|
9
|
+
This script copies the dashboard-essential fields across so the existing
|
|
10
|
+
dashboard surfaces (Trends, Top, Activity, Stats by Engagement Style, Cohort)
|
|
11
|
+
treat Instagram identically to Reddit/Twitter/LinkedIn.
|
|
12
|
+
|
|
13
|
+
Idempotent: skips rows already mirrored (matched on platform='instagram' AND
|
|
14
|
+
our_url=<IG permalink>). Safe to rerun. Called at end of run-instagram-daily.sh
|
|
15
|
+
so new posts mirror immediately, and once on-demand for backfill.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
python3 scripts/sync_ig_to_posts.py [--quiet] [--limit N]
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
27
|
+
from http_api import api_get, api_post
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def log(msg, quiet=False):
|
|
31
|
+
if not quiet:
|
|
32
|
+
print(msg)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_canonical_style_names(quiet=False):
|
|
36
|
+
"""Return the set of allowlisted engagement_style names.
|
|
37
|
+
|
|
38
|
+
Union of the hardcoded STYLES dict + the registry (seed + model_invented +
|
|
39
|
+
human_derived rows). Used to gate what we write to posts.engagement_style:
|
|
40
|
+
Claude sometimes stamps caption-style metadata with non-canonical labels
|
|
41
|
+
(e.g. 'studyly-rescue-arc') that never went through validate_or_register
|
|
42
|
+
on the IG render path. We refuse to mirror those into posts so they don't
|
|
43
|
+
pollute the dashboard's engagement-style A/B picker.
|
|
44
|
+
"""
|
|
45
|
+
names = set()
|
|
46
|
+
try:
|
|
47
|
+
from engagement_styles import get_all_styles
|
|
48
|
+
names.update((get_all_styles() or {}).keys())
|
|
49
|
+
except Exception as e:
|
|
50
|
+
log(f"[sync] WARNING — could not load canonical styles: {e!r}", quiet)
|
|
51
|
+
return names
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main():
|
|
55
|
+
parser = argparse.ArgumentParser()
|
|
56
|
+
parser.add_argument("--quiet", action="store_true")
|
|
57
|
+
parser.add_argument("--limit", type=int, default=None,
|
|
58
|
+
help="Cap rows processed (for testing).")
|
|
59
|
+
args = parser.parse_args()
|
|
60
|
+
|
|
61
|
+
# 2026-05-25: gate posts.engagement_style writes against the canonical
|
|
62
|
+
# registry. IG renders pre-pick a style (run-instagram-render.sh) and ask
|
|
63
|
+
# Claude to stamp metadata.engagement_style=<picked>, but Claude has been
|
|
64
|
+
# observed writing caption_style/description_style with off-list labels
|
|
65
|
+
# (e.g. 'studyly-rescue-arc') instead. Mirroring those into posts.* lets
|
|
66
|
+
# them pollute the engagement_style A/B picker. We mirror NULL for any
|
|
67
|
+
# value not in the canonical set; the orphan label remains in
|
|
68
|
+
# media_posts.metadata for forensics.
|
|
69
|
+
canonical_styles = _load_canonical_style_names(args.quiet)
|
|
70
|
+
|
|
71
|
+
query = {}
|
|
72
|
+
if args.limit:
|
|
73
|
+
query["limit"] = int(args.limit)
|
|
74
|
+
resp = api_get("/api/v1/media-posts/posted-instagram", query=query or None)
|
|
75
|
+
rows = (resp.get("data") or {}).get("rows") or []
|
|
76
|
+
log(f"[sync] media_posts: {len(rows)} posted IG rows", args.quiet)
|
|
77
|
+
|
|
78
|
+
inserted = 0
|
|
79
|
+
skipped = 0
|
|
80
|
+
for r in rows:
|
|
81
|
+
posted_urls = r["posted_urls"]
|
|
82
|
+
if isinstance(posted_urls, str):
|
|
83
|
+
posted_urls = json.loads(posted_urls)
|
|
84
|
+
ig_url = (posted_urls or {}).get("instagram")
|
|
85
|
+
if not ig_url:
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
# thread_url is NOT NULL; for original posts we self-reference
|
|
89
|
+
# (established pattern, 2,124 rows across other platforms).
|
|
90
|
+
metadata = r["metadata"]
|
|
91
|
+
if isinstance(metadata, str):
|
|
92
|
+
metadata = json.loads(metadata)
|
|
93
|
+
elif metadata is None:
|
|
94
|
+
metadata = {}
|
|
95
|
+
engagement_style = metadata.get("engagement_style") or metadata.get("caption_style")
|
|
96
|
+
if engagement_style and canonical_styles and engagement_style not in canonical_styles:
|
|
97
|
+
log(f"[sync] WARNING: dropping non-canonical engagement_style "
|
|
98
|
+
f"{engagement_style!r} for post-{r['post_number']} "
|
|
99
|
+
f"({r['target_account']}); mirroring NULL", args.quiet)
|
|
100
|
+
engagement_style = None
|
|
101
|
+
|
|
102
|
+
# The mirror endpoint is idempotent on (platform='instagram',
|
|
103
|
+
# our_url=ig_url): inserted=false means the row was already mirrored.
|
|
104
|
+
result = api_post(
|
|
105
|
+
"/api/v1/posts/mirror-instagram",
|
|
106
|
+
{
|
|
107
|
+
"ig_url": ig_url,
|
|
108
|
+
"caption_text": r["caption_text"] or "",
|
|
109
|
+
"target_account": r["target_account"] or "matt_diak",
|
|
110
|
+
"posted_at": r["posted_at"],
|
|
111
|
+
"project_name": r["project_name"],
|
|
112
|
+
"engagement_style": engagement_style,
|
|
113
|
+
},
|
|
114
|
+
)
|
|
115
|
+
rdata = result.get("data") or {}
|
|
116
|
+
posts_id = rdata.get("id")
|
|
117
|
+
if not rdata.get("inserted"):
|
|
118
|
+
skipped += 1
|
|
119
|
+
continue
|
|
120
|
+
inserted += 1
|
|
121
|
+
log(f"[sync] inserted post-{r['post_number']} ({r['target_account']}) -> {ig_url}", args.quiet)
|
|
122
|
+
|
|
123
|
+
# Attribute the freshly-mirrored posts row to any campaign that fired
|
|
124
|
+
# at post time. post_to_ig.py records metadata.applied_campaign_ids on
|
|
125
|
+
# the media_posts row (AI-disclosure labeling experiment etc.); forward
|
|
126
|
+
# each to /api/v1/campaigns/bump, which is idempotent (sets
|
|
127
|
+
# posts.campaign_id and advances the counter only on first attribution).
|
|
128
|
+
# Best-effort: a bump failure must not abort the sync.
|
|
129
|
+
applied = metadata.get("applied_campaign_ids") or []
|
|
130
|
+
if applied and posts_id:
|
|
131
|
+
for cid in applied:
|
|
132
|
+
try:
|
|
133
|
+
api_post(
|
|
134
|
+
"/api/v1/campaigns/bump",
|
|
135
|
+
{"table": "posts", "id": int(posts_id), "campaign_id": int(cid)},
|
|
136
|
+
)
|
|
137
|
+
log(f"[sync] campaign {cid} -> posts.id={posts_id}", args.quiet)
|
|
138
|
+
except SystemExit as e:
|
|
139
|
+
log(f"[sync] WARNING campaign {cid} bump failed: {e}", args.quiet)
|
|
140
|
+
except Exception as e:
|
|
141
|
+
log(f"[sync] WARNING campaign {cid} bump failed: {e}", args.quiet)
|
|
142
|
+
|
|
143
|
+
log(f"[sync] done: inserted={inserted} skipped_existing={skipped} total_scanned={len(rows)}", args.quiet)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
main()
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Regression test for the browser session-lock fix (2026-06-16).
|
|
3
|
+
|
|
4
|
+
Covers BOTH twitter_browser.py and linkedin_browser.py (same fix, ported). With
|
|
5
|
+
NO real browser, it exercises the three session-lock defects:
|
|
6
|
+
(a) dead python:PID holders must be reclaimed immediately (not after 300s)
|
|
7
|
+
(b) [shell-side, verified separately] no `rm -f` of the lockfile in pipelines
|
|
8
|
+
(c) lock acquisition must be atomic (two acquirers cannot both win)
|
|
9
|
+
|
|
10
|
+
Run:
|
|
11
|
+
/opt/homebrew/bin/python3 scripts/test_browser_lock.py
|
|
12
|
+
Exit 0 = all pass; non-zero with FAIL lines otherwise.
|
|
13
|
+
|
|
14
|
+
Canonical "did the fix survive / still work?" check. See
|
|
15
|
+
docs/twitter_browser_lock.md for the full verification playbook.
|
|
16
|
+
"""
|
|
17
|
+
import io
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
import time
|
|
24
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
25
|
+
|
|
26
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
27
|
+
for _cand in (HERE, os.path.join(HERE, "..")):
|
|
28
|
+
if os.path.exists(os.path.join(_cand, "twitter_browser.py")):
|
|
29
|
+
sys.path.insert(0, _cand)
|
|
30
|
+
break
|
|
31
|
+
import twitter_browser # noqa: E402
|
|
32
|
+
import linkedin_browser # noqa: E402
|
|
33
|
+
|
|
34
|
+
FAILS = []
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def check(name, cond, detail=""):
|
|
38
|
+
print(f"{'PASS' if cond else 'FAIL'} {name}" + (f" -- {detail}" if detail else ""))
|
|
39
|
+
if not cond:
|
|
40
|
+
FAILS.append(name)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def write_lock(mod, holder, ts):
|
|
44
|
+
with open(mod.LOCK_FILE, "w") as f:
|
|
45
|
+
json.dump({"session_id": holder, "timestamp": ts}, f)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_holder(mod):
|
|
49
|
+
with open(mod.LOCK_FILE) as f:
|
|
50
|
+
return json.load(f)["session_id"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def reset(mod):
|
|
54
|
+
mod._LOCK_SESSION_ID = f"python:{os.getpid()}"
|
|
55
|
+
mod._LOCK_INHERITED = False
|
|
56
|
+
try:
|
|
57
|
+
os.remove(mod.LOCK_FILE)
|
|
58
|
+
except OSError:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def dead_pid():
|
|
63
|
+
p = subprocess.Popen(["true"])
|
|
64
|
+
p.wait()
|
|
65
|
+
try:
|
|
66
|
+
os.kill(p.pid, 0)
|
|
67
|
+
return None
|
|
68
|
+
except ProcessLookupError:
|
|
69
|
+
return p.pid
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def run_suite(mod, giveup_substrings):
|
|
73
|
+
P = mod.__name__ # prefix for check names
|
|
74
|
+
tmpdir = tempfile.mkdtemp(prefix=f"brlock-{P}-")
|
|
75
|
+
mod.LOCK_FILE = os.path.join(tmpdir, "lock.json")
|
|
76
|
+
mod.LOCK_WAIT_MAX = 2
|
|
77
|
+
mod.LOCK_POLL_INTERVAL = 0.2
|
|
78
|
+
print(f"\n# {P}: LOCK_FILE={mod.LOCK_FILE} LOCK_WAIT_MAX={mod.LOCK_WAIT_MAX}")
|
|
79
|
+
|
|
80
|
+
check(f"{P}.fix_present: _is_python_holder_alive", hasattr(mod, "_is_python_holder_alive"))
|
|
81
|
+
check(f"{P}.fix_present: _try_take_lock (atomic)", hasattr(mod, "_try_take_lock"))
|
|
82
|
+
|
|
83
|
+
# (c) atomic take
|
|
84
|
+
reset(mod)
|
|
85
|
+
first = mod._try_take_lock()
|
|
86
|
+
second = mod._try_take_lock()
|
|
87
|
+
check(f"{P}.c.atomic_take: first wins, second loses", first is True and second is False,
|
|
88
|
+
f"first={first} second={second}")
|
|
89
|
+
check(f"{P}.c.atomic_take: file holds our id", read_holder(mod) == mod._LOCK_SESSION_ID)
|
|
90
|
+
|
|
91
|
+
# (a) dead python:PID holder reclaimed immediately
|
|
92
|
+
reset(mod)
|
|
93
|
+
dp = dead_pid()
|
|
94
|
+
if dp is None:
|
|
95
|
+
check(f"{P}.a.dead_python_reclaim", False, "could not obtain a dead pid")
|
|
96
|
+
else:
|
|
97
|
+
write_lock(mod, f"python:{dp}", int(time.time())) # RECENT ts
|
|
98
|
+
err = io.StringIO()
|
|
99
|
+
t0 = time.time()
|
|
100
|
+
with redirect_stderr(err):
|
|
101
|
+
mod._acquire_browser_lock()
|
|
102
|
+
elapsed = time.time() - t0
|
|
103
|
+
check(f"{P}.a.dead_python_reclaim: fast (<1s, not LOCK_WAIT_MAX)", elapsed < 1.0,
|
|
104
|
+
f"elapsed={elapsed:.2f}s")
|
|
105
|
+
check(f"{P}.a.dead_python_reclaim: lock now ours", read_holder(mod) == mod._LOCK_SESSION_ID)
|
|
106
|
+
check(f"{P}.a.dead_python_reclaim: marker reason=dead_python",
|
|
107
|
+
"reclaimed" in err.getvalue() and "reason=dead_python" in err.getvalue(),
|
|
108
|
+
err.getvalue().strip())
|
|
109
|
+
|
|
110
|
+
# LIVE python peer -> wait then give up
|
|
111
|
+
reset(mod)
|
|
112
|
+
peer = subprocess.Popen(["sleep", "30"])
|
|
113
|
+
try:
|
|
114
|
+
write_lock(mod, f"python:{peer.pid}", int(time.time()))
|
|
115
|
+
out, err = io.StringIO(), io.StringIO()
|
|
116
|
+
t0 = time.time()
|
|
117
|
+
code = None
|
|
118
|
+
try:
|
|
119
|
+
with redirect_stdout(out), redirect_stderr(err):
|
|
120
|
+
mod._acquire_browser_lock()
|
|
121
|
+
except SystemExit as e:
|
|
122
|
+
code = e.code
|
|
123
|
+
elapsed = time.time() - t0
|
|
124
|
+
check(f"{P}.live_peer.giveup: exits 1", code == 1, f"code={code}")
|
|
125
|
+
check(f"{P}.live_peer.giveup: waited ~LOCK_WAIT_MAX", elapsed >= mod.LOCK_WAIT_MAX * 0.8,
|
|
126
|
+
f"elapsed={elapsed:.2f}s")
|
|
127
|
+
payload = out.getvalue()
|
|
128
|
+
for sub in giveup_substrings:
|
|
129
|
+
check(f"{P}.live_peer.giveup: payload has '{sub}'", sub in payload, payload.strip())
|
|
130
|
+
finally:
|
|
131
|
+
peer.terminate()
|
|
132
|
+
peer.wait()
|
|
133
|
+
|
|
134
|
+
# re-entrant -> take fast, refresh timestamp
|
|
135
|
+
reset(mod)
|
|
136
|
+
old_ts = int(time.time()) - 120
|
|
137
|
+
write_lock(mod, mod._LOCK_SESSION_ID, old_ts)
|
|
138
|
+
t0 = time.time()
|
|
139
|
+
mod._acquire_browser_lock()
|
|
140
|
+
check(f"{P}.reentrant: fast", time.time() - t0 < 1.0)
|
|
141
|
+
with open(mod.LOCK_FILE) as f:
|
|
142
|
+
new_ts = json.load(f)["timestamp"]
|
|
143
|
+
check(f"{P}.reentrant: timestamp refreshed", new_ts > old_ts, f"old={old_ts} new={new_ts}")
|
|
144
|
+
|
|
145
|
+
# dead UUID holder -> reclaim
|
|
146
|
+
reset(mod)
|
|
147
|
+
write_lock(mod, "deadbeef-0000-0000-0000-000000000000", int(time.time()))
|
|
148
|
+
err = io.StringIO()
|
|
149
|
+
with redirect_stderr(err):
|
|
150
|
+
mod._acquire_browser_lock()
|
|
151
|
+
check(f"{P}.dead_uuid_reclaim: lock now ours", read_holder(mod) == mod._LOCK_SESSION_ID)
|
|
152
|
+
check(f"{P}.dead_uuid_reclaim: marker reason=dead_uuid", "reason=dead_uuid" in err.getvalue(),
|
|
153
|
+
err.getvalue().strip())
|
|
154
|
+
|
|
155
|
+
# expired holder -> reclaim
|
|
156
|
+
reset(mod)
|
|
157
|
+
write_lock(mod, "weird:holder:form", int(time.time()) - (mod.LOCK_EXPIRY + 50))
|
|
158
|
+
err = io.StringIO()
|
|
159
|
+
with redirect_stderr(err):
|
|
160
|
+
mod._acquire_browser_lock()
|
|
161
|
+
check(f"{P}.expired_reclaim: lock now ours", read_holder(mod) == mod._LOCK_SESSION_ID)
|
|
162
|
+
check(f"{P}.expired_reclaim: marker reason=expired", "reason=expired" in err.getvalue(),
|
|
163
|
+
err.getvalue().strip())
|
|
164
|
+
|
|
165
|
+
# cold start -> fast + silent
|
|
166
|
+
reset(mod)
|
|
167
|
+
err = io.StringIO()
|
|
168
|
+
t0 = time.time()
|
|
169
|
+
with redirect_stderr(err):
|
|
170
|
+
mod._acquire_browser_lock()
|
|
171
|
+
check(f"{P}.cold_start: fast + silent", (time.time() - t0) < 1.0 and "reclaim" not in err.getvalue())
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
os.remove(mod.LOCK_FILE)
|
|
175
|
+
except OSError:
|
|
176
|
+
pass
|
|
177
|
+
os.rmdir(tmpdir)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# twitter giveup: {"success": false, "error": "...locked by session ... peer alive..."}
|
|
181
|
+
run_suite(twitter_browser, ["locked by session", "peer alive"])
|
|
182
|
+
# linkedin giveup: {"ok": false, "error": "profile_locked", "detail": "...peer_alive=1"}
|
|
183
|
+
run_suite(linkedin_browser, ["profile_locked", "peer_alive"])
|
|
184
|
+
|
|
185
|
+
print()
|
|
186
|
+
if FAILS:
|
|
187
|
+
print(f"RESULT: {len(FAILS)} FAILED -> {FAILS}")
|
|
188
|
+
sys.exit(1)
|
|
189
|
+
print("RESULT: ALL PASS")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# End-to-end smoke test for the installation auth lane.
|
|
3
|
+
#
|
|
4
|
+
# Run AFTER:
|
|
5
|
+
# 1. ~/social-autoposter-website/scripts/migrate-installations.sql applied
|
|
6
|
+
# against $DATABASE_URL.
|
|
7
|
+
# 2. Latest social-autoposter-website deployed to Vercel.
|
|
8
|
+
#
|
|
9
|
+
# Hits:
|
|
10
|
+
# - GET heartbeat with no header (expect 400)
|
|
11
|
+
# - POST heartbeat with header (expect 200 + installation row)
|
|
12
|
+
# - GET heartbeat with header (expect 200 + same installation row)
|
|
13
|
+
# - GET /api/v1/replies?limit=1 (expect 200 with install header, no bearer)
|
|
14
|
+
#
|
|
15
|
+
# No data is inserted to `replies` here; this only exercises the auth path.
|
|
16
|
+
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
|
|
19
|
+
BASE_URL="${BASE_URL:-https://s4l.ai}"
|
|
20
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
21
|
+
|
|
22
|
+
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
|
23
|
+
HDR=$("$PYTHON_BIN" "$SCRIPT_DIR/identity.py" header)
|
|
24
|
+
echo "install_id: $("$PYTHON_BIN" -c "import base64,json,sys; d=json.loads(base64.b64decode(sys.argv[1])); print(d['install_id'])" "$HDR")"
|
|
25
|
+
echo "base_url: $BASE_URL"
|
|
26
|
+
echo
|
|
27
|
+
|
|
28
|
+
step() { echo; echo "=== $1 ==="; }
|
|
29
|
+
|
|
30
|
+
step "1) GET /api/v1/installations/heartbeat (no header) -> expect 400"
|
|
31
|
+
curl -sS -w "\nHTTP %{http_code}\n" "$BASE_URL/api/v1/installations/heartbeat" | tail -20
|
|
32
|
+
|
|
33
|
+
step "2) POST /api/v1/installations/heartbeat (with header) -> expect 200"
|
|
34
|
+
curl -sS -w "\nHTTP %{http_code}\n" \
|
|
35
|
+
-X POST \
|
|
36
|
+
-H "X-Installation: $HDR" \
|
|
37
|
+
-H "content-type: application/json" \
|
|
38
|
+
-d '{}' \
|
|
39
|
+
"$BASE_URL/api/v1/installations/heartbeat" | tail -40
|
|
40
|
+
|
|
41
|
+
step "3) GET /api/v1/installations/heartbeat (with header) -> expect 200"
|
|
42
|
+
curl -sS -w "\nHTTP %{http_code}\n" \
|
|
43
|
+
-H "X-Installation: $HDR" \
|
|
44
|
+
"$BASE_URL/api/v1/installations/heartbeat" | tail -40
|
|
45
|
+
|
|
46
|
+
step "4) GET /api/v1/replies?limit=1 (with install header, no bearer) -> expect 200"
|
|
47
|
+
curl -sS -w "\nHTTP %{http_code}\n" \
|
|
48
|
+
-H "X-Installation: $HDR" \
|
|
49
|
+
"$BASE_URL/api/v1/replies?limit=1" | tail -10
|
|
50
|
+
|
|
51
|
+
echo
|
|
52
|
+
echo "Done. If all 4 returned the expected codes, the install lane is wired."
|
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
6. activity progress reflects the approved burst total, not each 1-item call
|
|
12
|
+
"""
|
|
13
|
+
import os
|
|
14
|
+
import queue
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import types
|
|
19
|
+
|
|
20
|
+
HERE = os.path.join(os.path.dirname(__file__), "..", "mcp", "menubar")
|
|
21
|
+
sys.path.insert(0, os.path.abspath(HERE))
|
|
22
|
+
|
|
23
|
+
# --- stub heavy deps so the import is headless --------------------------------
|
|
24
|
+
rumps = types.ModuleType("rumps")
|
|
25
|
+
class _App:
|
|
26
|
+
def __init__(self, *a, **k):
|
|
27
|
+
pass
|
|
28
|
+
rumps.App = _App
|
|
29
|
+
rumps.Timer = lambda *a, **k: types.SimpleNamespace(start=lambda: None, stop=lambda: None)
|
|
30
|
+
rumps.MenuItem = lambda *a, **k: object()
|
|
31
|
+
rumps.separator = object()
|
|
32
|
+
rumps.notification = lambda *a, **k: None
|
|
33
|
+
sys.modules["rumps"] = rumps
|
|
34
|
+
|
|
35
|
+
sentry_init = types.ModuleType("sentry_init")
|
|
36
|
+
sentry_init.init_sentry = lambda *a, **k: None
|
|
37
|
+
sentry_init.capture = lambda *a, **k: None
|
|
38
|
+
sys.modules["sentry_init"] = sentry_init
|
|
39
|
+
|
|
40
|
+
# Track concurrency + record every post_drafts call.
|
|
41
|
+
overlap_detected = []
|
|
42
|
+
inflight = {"n": 0}
|
|
43
|
+
inflight_lock = threading.Lock()
|
|
44
|
+
calls = []
|
|
45
|
+
activity_events = []
|
|
46
|
+
|
|
47
|
+
def fake_post_drafts(batch_id, post=None, edits=None, timeout=900, activity_label=None):
|
|
48
|
+
with inflight_lock:
|
|
49
|
+
inflight["n"] += 1
|
|
50
|
+
if inflight["n"] > 1:
|
|
51
|
+
overlap_detected.append(True)
|
|
52
|
+
calls.append(
|
|
53
|
+
{
|
|
54
|
+
"batch": batch_id,
|
|
55
|
+
"post": post or [],
|
|
56
|
+
"edits": edits or [],
|
|
57
|
+
"activity_label": activity_label,
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
time.sleep(0.15) # simulate a slow post so overlaps would be caught
|
|
61
|
+
with inflight_lock:
|
|
62
|
+
inflight["n"] -= 1
|
|
63
|
+
# mimic the real shape: posted count
|
|
64
|
+
n_posted = len(post or []) + len(edits or [])
|
|
65
|
+
return {"posted": n_posted}
|
|
66
|
+
|
|
67
|
+
st = types.ModuleType("s4l_state")
|
|
68
|
+
st.post_drafts = fake_post_drafts
|
|
69
|
+
st.write_activity = lambda state, label: activity_events.append((state, label))
|
|
70
|
+
st.accessibility_trusted = lambda: True
|
|
71
|
+
st.clear_review_request = lambda: None
|
|
72
|
+
sys.modules["s4l_state"] = st
|
|
73
|
+
|
|
74
|
+
import s4l_menubar # noqa: E402
|
|
75
|
+
|
|
76
|
+
# --- build an instance without running rumps.App.__init__ ---------------------
|
|
77
|
+
app = object.__new__(s4l_menubar.S4LMenuBar)
|
|
78
|
+
app._post_q = queue.Queue()
|
|
79
|
+
app._post_worker = None
|
|
80
|
+
app._review_lock = threading.Lock()
|
|
81
|
+
app._panel_open = True
|
|
82
|
+
app._posts_outstanding = 0
|
|
83
|
+
app._posting_batch_total = 0
|
|
84
|
+
app._posting_batch_done = 0
|
|
85
|
+
app._review_active = False
|
|
86
|
+
app._notify = lambda title, msg: None # silence Notification Center
|
|
87
|
+
|
|
88
|
+
BATCH = "review-queue"
|
|
89
|
+
|
|
90
|
+
# Approve a quick burst (as if the user clicked Approve on several cards fast),
|
|
91
|
+
# one edited, plus a rejected card that must NOT post.
|
|
92
|
+
decisions = [
|
|
93
|
+
{"n": 1, "approved": True, "text": "reply one", "edited": False},
|
|
94
|
+
{"n": 2, "approved": True, "text": "edited two", "edited": True},
|
|
95
|
+
{"n": 3, "approved": False, "text": "skip", "edited": False},
|
|
96
|
+
{"n": 4, "approved": True, "text": "reply four", "edited": False},
|
|
97
|
+
]
|
|
98
|
+
for d in decisions:
|
|
99
|
+
app._on_card_decision(BATCH, d)
|
|
100
|
+
time.sleep(0.02) # tight succession -> overlap would happen if not serialized
|
|
101
|
+
|
|
102
|
+
# Panel closes while posts may still be draining.
|
|
103
|
+
app._on_review_closed(BATCH, decisions)
|
|
104
|
+
|
|
105
|
+
# Wait for the queue to drain.
|
|
106
|
+
deadline = time.time() + 10
|
|
107
|
+
while time.time() < deadline:
|
|
108
|
+
with app._review_lock:
|
|
109
|
+
if app._posts_outstanding == 0 and app._post_q.empty():
|
|
110
|
+
break
|
|
111
|
+
time.sleep(0.05)
|
|
112
|
+
|
|
113
|
+
# --- assertions ---------------------------------------------------------------
|
|
114
|
+
fail = []
|
|
115
|
+
if overlap_detected:
|
|
116
|
+
fail.append(f"posts overlapped ({len(overlap_detected)} times) — not serialized")
|
|
117
|
+
posted_ns = [(c["post"], c["edits"]) for c in calls]
|
|
118
|
+
expected = [([1], []), ([], [{"n": 2, "text": "edited two"}]), ([4], [])]
|
|
119
|
+
if posted_ns != expected:
|
|
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}")
|
|
128
|
+
if any(c["post"] == [3] or any(e.get("n") == 3 for e in c["edits"]) for c in calls):
|
|
129
|
+
fail.append("rejected card #3 was posted")
|
|
130
|
+
with app._review_lock:
|
|
131
|
+
if app._posts_outstanding != 0:
|
|
132
|
+
fail.append(f"_posts_outstanding leaked: {app._posts_outstanding}")
|
|
133
|
+
if app._review_active:
|
|
134
|
+
fail.append("_review_active stuck true after drain + panel closed")
|
|
135
|
+
|
|
136
|
+
if fail:
|
|
137
|
+
print("FAIL:")
|
|
138
|
+
for f in fail:
|
|
139
|
+
print(" -", f)
|
|
140
|
+
sys.exit(1)
|
|
141
|
+
print("PASS: 3 posts, serialized, FIFO order, #3 skipped, flags settled idle.")
|
|
142
|
+
print(" calls:", posted_ns)
|