@agentikos/omega-os 0.19.38 → 0.19.39

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.
Files changed (31) hide show
  1. package/omega/Agentik_Engine/omega_engine/__init__.py +1 -1
  2. package/omega/Agentik_Engine/omega_engine/__pycache__/cli.cpython-313.pyc +0 -0
  3. package/omega/Agentik_Engine/omega_engine/__pycache__/paperclip_bridge.cpython-313.pyc +0 -0
  4. package/omega/Agentik_Engine/omega_engine/__pycache__/prompt_audit.cpython-313.pyc +0 -0
  5. package/omega/Agentik_Engine/omega_engine/__pycache__/tmux.cpython-313.pyc +0 -0
  6. package/omega/Agentik_Engine/omega_engine/__pycache__/tui.cpython-313.pyc +0 -0
  7. package/omega/Agentik_Engine/omega_engine/cli.py +39 -0
  8. package/omega/Agentik_Engine/omega_engine/paperclip_bridge.py +110 -0
  9. package/omega/Agentik_Engine/omega_engine/prompt_audit.py +395 -0
  10. package/omega/Agentik_Engine/omega_engine/tmux.py +16 -0
  11. package/omega/Agentik_Engine/omega_engine/tui.py +269 -67
  12. package/omega/Agentik_Engine/pyproject.toml +1 -1
  13. package/omega/Agentik_Engine/tests/__pycache__/test_paperclip_status.cpython-313-pytest-8.4.2.pyc +0 -0
  14. package/omega/Agentik_Engine/tests/__pycache__/test_paperclip_status.cpython-313.pyc +0 -0
  15. package/omega/Agentik_Engine/tests/__pycache__/test_prompt_audit.cpython-313-pytest-8.4.2.pyc +0 -0
  16. package/omega/Agentik_Engine/tests/__pycache__/test_prompt_audit.cpython-313.pyc +0 -0
  17. package/omega/Agentik_Engine/tests/__pycache__/test_tui_runtime.cpython-313-pytest-8.4.2.pyc +0 -0
  18. package/omega/Agentik_Engine/tests/__pycache__/test_tui_runtime.cpython-313.pyc +0 -0
  19. package/omega/Agentik_Engine/tests/test_paperclip_status.py +142 -0
  20. package/omega/Agentik_Engine/tests/test_prompt_audit.py +199 -0
  21. package/omega/Agentik_Engine/tests/test_tui_runtime.py +106 -0
  22. package/omega/Agentik_SSOT/VERSION +1 -1
  23. package/omega/Agentik_SSOT/docs/AUDIT-V0.19.39.md +161 -0
  24. package/omega/Agentik_SSOT/rules/audit-gates.md +189 -0
  25. package/omega/Agentik_SSOT/rules/constitution.md +7 -0
  26. package/omega/Agentik_SSOT/rules/orchestration.md +215 -0
  27. package/omega/Agentik_SSOT/rules/prompt-protocols.md +219 -0
  28. package/omega/Agentik_SSOT/rules/scope-safety.md +197 -0
  29. package/omega/Agentik_SSOT/rules/three-laws.md +214 -0
  30. package/omega/Agentik_SSOT/rules/verified-completion.md +216 -0
  31. package/package.json +1 -1
@@ -462,46 +462,180 @@ def _arrow_menu() -> int:
462
462
  # `omega_engine.provider_state` module both can read.
463
463
  from omega_engine.provider_state import active_provider as _active_provider
464
464
 
465
+ # v0.19.39 — chat-first redesign. The TUI now opens on CONVERSATIONS
466
+ # (live tmux sessions: AISB chat, Hermès chat, active Oracles per
467
+ # project, active Workers per task) with ●/○ status dots. Setup,
468
+ # config, infra, audits, scrape, etc. move into sub-menus opened from
469
+ # a short "MENU" section. Reasoning: the user spends 99% of their
470
+ # time in conversations — the menu should reflect that, not bury the
471
+ # chats below 6 sections of admin options.
472
+ DOT_ON = f"{ORANGE}●{RST}" # alive
473
+ DOT_OFF = f"{MUTED}○{RST}" # not running
474
+
475
+ def _dot(alive: bool) -> str:
476
+ return DOT_ON if alive else DOT_OFF
477
+
478
+ def _conv_label(name: str, hint: str, alive: bool) -> str:
479
+ """Conversation row: `<dot> <name> <dim hint>`."""
480
+ dot = _dot(alive)
481
+ return f" {dot} {name:<32}{DIM}{hint}{RST}"
482
+
483
+ def _paperclip_status_quick() -> tuple[bool, str]:
484
+ """Lightweight inline Paperclip probe — short timeout, no raise.
485
+ Returns (alive, hint). Hint is empty when down, ``localhost:8080``
486
+ (or whatever port the bridge reports) when up. Uses the new
487
+ ``paperclip_bridge.is_running()`` from chantier 4 when present;
488
+ falls back gracefully to a plain TCP probe.
489
+ """
490
+ try:
491
+ from omega_engine.paperclip_bridge import is_running as _pcst
492
+ st = _pcst(HOME)
493
+ if getattr(st, "running", False):
494
+ port = getattr(st, "port", None) or 8080
495
+ return True, f"localhost:{port}"
496
+ except Exception: # noqa: BLE001
497
+ # Fallback: a 200ms TCP probe to 127.0.0.1:8080.
498
+ import socket as _s
499
+ try:
500
+ with _s.create_connection(("127.0.0.1", 8080), timeout=0.2):
501
+ return True, "localhost:8080"
502
+ except OSError:
503
+ pass
504
+ return False, ""
505
+
506
+ def _active_oracles_and_workers() -> tuple[list, list]:
507
+ """Categorize live tmux sessions into (oracles, workers).
508
+ Each entry is the ``TmuxSession`` instance."""
509
+ all_sessions = tmux.list_sessions(HOME)
510
+ oracles = sorted([s for s in all_sessions if s.category == "oracle"],
511
+ key=lambda x: x.name)
512
+ workers = sorted([s for s in all_sessions if s.category == "worker"],
513
+ key=lambda x: x.name)
514
+ return oracles, workers
515
+
465
516
  def _build_items() -> list[tuple[str, str]]:
466
- """Return (display, action_key) — section headers have key '__sep__'."""
517
+ """Return (display, action_key) — section headers have key '__sep__'.
518
+
519
+ v0.19.39 layout (chat-first):
520
+ 1. CONVERSATIONS — AISB / Hermès / live Oracles / live Workers
521
+ 2. QUICK ACTIONS — new chat, new project, run mission, Paperclip
522
+ 3. MENU — sub-menus (audits, setup, infra, health)
523
+ 4. EXIT
524
+ """
467
525
  provider = _active_provider()
468
- sessions = tmux.list_sessions(HOME)
469
- return [
470
- (_section("CHAT"), "__sep__"),
471
- (_label("AISB master chat", "→ claude (Max OAuth)"), "open:aisb"),
472
- (_label("Hermès companion", "→ claude (Anthropic API)"), "open:hermes"),
473
- (_label("Switch LLM", f"current: {provider}"), "switch:provider"),
474
- ("", "__sep__"),
475
- (_section("PROJECTS"), "__sep__"),
476
- (_label("New project", "Genesis pipeline"), "genesis:new"),
477
- (_label("Open project shell"), "project:open"),
478
- ("", "__sep__"),
479
- (_section("AUDITS & MISSIONS"), "__sep__"),
480
- (_label("Run a mission", "verified completion"), "run:mission"),
481
- (_label("Quality Arsenal", "17 forensic audits"), "audit:menu"),
482
- ("", "__sep__"),
483
- (_section("INFRASTRUCTURE"), "__sep__"),
484
- (_label("Sessions", f"{len(sessions)} active"), "sessions:list"),
485
- (_label("Accounts", "Claude Max pool"), "accounts:menu"),
486
- (_label("Vault", "encrypted secrets"), "vault:menu"),
487
- ("", "__sep__"),
488
- (_section("HEALTH"), "__sep__"),
489
- (_label("omega doctor", "full health check"), "cmd:doctor"),
490
- (_label("omega status", "task state"), "cmd:status"),
491
- ("", "__sep__"),
492
- (_section("SCRAPE"), "__sep__"),
493
- (_label("Stealth scrape", "CloakBrowser"), "scrape:cloak"),
494
- (_label("Fast scrape", "Scrapling"), "scrape:scrapling"),
495
- ("", "__sep__"),
496
- (_section("GOVERNANCE (Paperclip L0)"), "__sep__"),
497
- (_label("Paperclip dashboard","web UI for 14 agents"), "paperclip:dashboard"),
498
- (_label("Paperclip status", "bridge health"), "paperclip:status"),
499
- (_label("Paperclip register","(re)sync OmegaOS company"), "paperclip:register"),
500
- ("", "__sep__"),
501
- (_section("EXIT"), "__sep__"),
502
- (_label("Detach", "session keeps running"), "detach"),
503
- (_label("Quit Omega", "kills the tmux session"), "quit:kill"),
504
- ]
526
+ aisb_alive = tmux.omega_window_alive("aisb")
527
+ hermes_alive = tmux.omega_window_alive("hermes")
528
+ oracles, workers = _active_oracles_and_workers()
529
+ paperclip_alive, paperclip_hint = _paperclip_status_quick()
530
+
531
+ items: list[tuple[str, str]] = []
532
+
533
+ # ── CONVERSATIONS ───────────────────────────────────────────────
534
+ items.append((_section("CONVERSATIONS"), "__sep__"))
535
+ items.append((_conv_label("AISB master", "claude (Max OAuth)", aisb_alive), "open:aisb"))
536
+ items.append((_conv_label("Hermès", "claude (Anthropic API)", hermes_alive), "open:hermes"))
537
+
538
+ # Per-project live oracles + workers (only when there are any —
539
+ # don't waste a row when the user has nothing running).
540
+ if oracles or workers:
541
+ items.append(("", "__sep__"))
542
+ if oracles:
543
+ items.append((f" {DIM}— Active Oracles ({len(oracles)}) —{RST}", "__sep__"))
544
+ for s in oracles:
545
+ proj = s.project or "?"
546
+ hint = f"project: {proj}"
547
+ items.append((_conv_label(s.name, hint, True), f"attach:{s.name}"))
548
+ if workers:
549
+ items.append((f" {DIM}— Active Workers ({len(workers)}) —{RST}", "__sep__"))
550
+ for s in workers:
551
+ # Extract task from `<proj>-worker-N-<task>`.
552
+ task = s.name.split("worker-", 1)[-1].split("-", 1)[-1] if "worker-" in s.name else "?"
553
+ hint = f"task: {task}"
554
+ items.append((_conv_label(s.name, hint, True), f"attach:{s.name}"))
555
+
556
+ items.append(("", "__sep__"))
557
+
558
+ # ── QUICK ACTIONS ───────────────────────────────────────────────
559
+ items.append((_section("QUICK ACTIONS"), "__sep__"))
560
+ items.append((_label("+ New AISB chat", "fresh session"), "open:aisb:new"))
561
+ items.append((_label("+ New Hermès chat", "fresh session"), "open:hermes:new"))
562
+ items.append((_label("+ New project", "Genesis pipeline"), "genesis:new"))
563
+ items.append((_label("Run a mission", "verified completion"), "run:mission"))
564
+ # Paperclip dashboard with live status indicator.
565
+ pclbl = f"Paperclip dashboard"
566
+ pcdot = _dot(paperclip_alive)
567
+ pchint = paperclip_hint if paperclip_alive else "not running"
568
+ items.append((f" {pcdot} {pclbl:<32}{DIM}{pchint}{RST}", "paperclip:dashboard"))
569
+
570
+ items.append(("", "__sep__"))
571
+
572
+ # ── MENU (sub-menus for everything else) ────────────────────────
573
+ items.append((_section("MENU"), "__sep__"))
574
+ items.append((_label("Quality Arsenal", "17 forensic audits"), "submenu:audits"))
575
+ items.append((_label("Setup & config", f"LLM: {provider}"), "submenu:setup"))
576
+ items.append((_label("Infrastructure", "sessions, scrape"), "submenu:infra"))
577
+ items.append((_label("Health checks", "doctor, status"), "submenu:health"))
578
+ items.append((_label("Paperclip governance", "register, status"), "submenu:paperclip"))
579
+
580
+ items.append(("", "__sep__"))
581
+
582
+ # ── EXIT ────────────────────────────────────────────────────────
583
+ items.append((_section("EXIT"), "__sep__"))
584
+ items.append((_label("Detach", "session keeps running"), "detach"))
585
+ items.append((_label("Quit Omega", "kills the tmux session"), "quit:kill"))
586
+
587
+ return items
588
+
589
+ def _submenu_items(name: str) -> list[tuple[str, str]]:
590
+ """Return (display, action_key) for one of the named sub-menus.
591
+ Every sub-menu ends with a synthetic '← back' row that aborts the
592
+ cascaded fzf with key 'back'."""
593
+ if name == "audits":
594
+ return [
595
+ (_section("QUALITY ARSENAL"), "__sep__"),
596
+ (_label("List all 17 audits"), "audit:menu"),
597
+ (_label("Run an audit", "interactive"), "audit:run"),
598
+ ("", "__sep__"),
599
+ (_label("← back"), "back"),
600
+ ]
601
+ if name == "setup":
602
+ return [
603
+ (_section("SETUP & CONFIG"), "__sep__"),
604
+ (_label("Switch LLM provider"), "switch:provider"),
605
+ (_label("Accounts", "Claude Max pool"), "accounts:menu"),
606
+ (_label("Vault", "encrypted secrets"), "vault:menu"),
607
+ ("", "__sep__"),
608
+ (_label("← back"), "back"),
609
+ ]
610
+ if name == "infra":
611
+ return [
612
+ (_section("INFRASTRUCTURE"), "__sep__"),
613
+ (_label("List tmux sessions"), "sessions:list"),
614
+ (_label("Open project shell"), "project:open"),
615
+ ("", "__sep__"),
616
+ (_label("Stealth scrape", "CloakBrowser"), "scrape:cloak"),
617
+ (_label("Fast scrape", "Scrapling"), "scrape:scrapling"),
618
+ ("", "__sep__"),
619
+ (_label("← back"), "back"),
620
+ ]
621
+ if name == "health":
622
+ return [
623
+ (_section("HEALTH"), "__sep__"),
624
+ (_label("omega doctor", "full health check"), "cmd:doctor"),
625
+ (_label("omega status", "task state"), "cmd:status"),
626
+ ("", "__sep__"),
627
+ (_label("← back"), "back"),
628
+ ]
629
+ if name == "paperclip":
630
+ return [
631
+ (_section("PAPERCLIP GOVERNANCE (L0)"), "__sep__"),
632
+ (_label("Open dashboard"), "paperclip:dashboard"),
633
+ (_label("Status", "bridge health"), "paperclip:status"),
634
+ (_label("Register", "(re)sync OmegaOS company"), "paperclip:register"),
635
+ ("", "__sep__"),
636
+ (_label("← back"), "back"),
637
+ ]
638
+ return [(_label("← back"), "back")]
505
639
 
506
640
  def _run_inline(cmd_argv, *, shell: bool = False) -> None:
507
641
  os.system("clear")
@@ -525,23 +659,29 @@ def _arrow_menu() -> int:
525
659
  except (EOFError, KeyboardInterrupt):
526
660
  return ""
527
661
 
528
- while True:
529
- items = _build_items()
530
- # v0.19.37 bulletproof matching via tab-delimited index column.
531
- # fzf with --ansi STRIPS escape codes from stdout, which broke
532
- # the old `display == pick` exact-string match (every pick fell
533
- # through, menu just reloaded). Now we prefix each line with a
534
- # stable index (`<i>\t<display>`) and use --with-nth=2.. so fzf
535
- # only DISPLAYS the second column but RETURNS the full line.
536
- # We split off the index and look up the action — survives any
537
- # ANSI/whitespace transformation fzf might apply.
662
+ def _pick(items: list[tuple[str, str]],
663
+ header_extra: str = "") -> str | None:
664
+ """Render one fzf pass over ``items`` and return the chosen
665
+ action key (the second element of the picked tuple), or ``None``
666
+ on Esc / Ctrl-C / error.
667
+
668
+ Skips section headers and blank rows automatically — the caller
669
+ never has to worry about ``__sep__`` picks.
670
+
671
+ v0.19.37 lock-in: input is tab-delimited ``<idx>\\t<display>``,
672
+ fzf --with-nth=2.. shows only the visual column, and we parse
673
+ the index column out of stdout — survives any ANSI/whitespace
674
+ transformation fzf applies.
675
+ """
538
676
  lines = []
539
- for i, (disp, key) in enumerate(items):
677
+ for i, (disp, _key) in enumerate(items):
540
678
  lines.append(f"{i}\t{disp}")
541
679
  header = (
542
680
  f"{ORANGE}{BOLD}Ω Omega OS v{__version__}{RST} "
543
681
  f"{MUTED}• ↑↓ navigate • ↵ pick • / search • Esc refresh{RST}"
544
682
  )
683
+ if header_extra:
684
+ header = f"{header}\n{MUTED}{header_extra}{RST}"
545
685
  try:
546
686
  proc = subprocess.run(
547
687
  ["fzf",
@@ -577,27 +717,50 @@ def _arrow_menu() -> int:
577
717
  input="\n".join(lines), capture_output=True, text=True,
578
718
  )
579
719
  except (KeyboardInterrupt, subprocess.SubprocessError):
580
- return 0
720
+ return None
581
721
  if proc.returncode != 0:
582
- # Esc → refresh.
583
- continue
722
+ return None
584
723
  pick = proc.stdout.rstrip("\n")
585
- # Parse the index column. The tab is always the first \t in the
586
- # picked line (the rest of the display may contain colour codes
587
- # but no tabs).
588
724
  if "\t" not in pick:
589
- continue
590
- idx_str, _disp_part = pick.split("\t", 1)
725
+ return None
726
+ idx_str, _ = pick.split("\t", 1)
591
727
  try:
592
728
  idx = int(idx_str)
593
729
  except ValueError:
594
- continue
730
+ return None
595
731
  if not (0 <= idx < len(items)):
596
- continue
732
+ return None
597
733
  action = items[idx][1]
598
734
  if action == "__sep__":
599
- # User picked a section header — silent reroll.
735
+ return None
736
+ return action
737
+
738
+ def _open_submenu(name: str) -> str | None:
739
+ """Render a sub-menu in a loop until the user picks an action OR
740
+ chooses 'back'. Returns the action key to dispatch (the caller
741
+ runs it), or None when the user backs out / Esc'd.
742
+ """
743
+ while True:
744
+ sub_items = _submenu_items(name)
745
+ picked = _pick(sub_items, header_extra=f"sub-menu: {name} · Esc/← back to main")
746
+ if picked is None or picked == "back":
747
+ return None
748
+ return picked
749
+
750
+ while True:
751
+ items = _build_items()
752
+ action = _pick(items)
753
+ if action is None:
754
+ # Esc → just refresh the conversations panel (cheap — re-renders
755
+ # with live tmux state).
600
756
  continue
757
+ # Sub-menu indirection: when the user picks a sub-menu, render it
758
+ # in its own loop until they pick a real action or back out.
759
+ if action.startswith("submenu:"):
760
+ sub_name = action.split(":", 1)[1]
761
+ action = _open_submenu(sub_name)
762
+ if action is None:
763
+ continue
601
764
 
602
765
  # === Dispatch ===
603
766
  if action == "detach":
@@ -606,19 +769,50 @@ def _arrow_menu() -> int:
606
769
  if action == "quit:kill":
607
770
  subprocess.run(["tmux", "kill-session", "-t", "Omega"])
608
771
  return 0
609
- if action == "open:aisb":
610
- # v0.19.31 open as a WINDOW in Omega, not a separate session.
611
- # User stays in Omega; Ctrl-b 0 returns to menu, Ctrl-b d
612
- # detaches the whole Omega (and `omega` brings them back).
772
+ # v0.19.39 attach to a live Oracle / Worker tmux session. The
773
+ # menu lists them as `attach:<session_name>`. We use
774
+ # `select-window` when the session is already a window inside
775
+ # Omega; otherwise we switch the client to that session.
776
+ if action.startswith("attach:"):
777
+ target = action.split(":", 1)[1]
778
+ # If the target is a window of Omega, just select it. Else
779
+ # switch-client to that session (works under nested tmux).
780
+ wnames = subprocess.run(
781
+ ["tmux", "list-windows", "-t", "Omega", "-F", "#W"],
782
+ capture_output=True, text=True,
783
+ )
784
+ if (wnames.returncode == 0
785
+ and target in (wnames.stdout or "").splitlines()):
786
+ subprocess.run(["tmux", "select-window",
787
+ "-t", f"Omega:{target}"])
788
+ else:
789
+ # Switch to the foreign session. Use switch-client (works
790
+ # when we're already inside a tmux client) or fall back to
791
+ # attach for non-tmux contexts.
792
+ rc = subprocess.run(
793
+ ["tmux", "switch-client", "-t", target],
794
+ capture_output=True,
795
+ ).returncode
796
+ if rc != 0:
797
+ _run_inline(f"echo ' cannot attach to {target} — is it alive?' "
798
+ f"&& tmux has-session -t {target} && "
799
+ f"echo ' tmux says: yes' || echo ' tmux says: no'",
800
+ shell=True)
801
+ continue
802
+ # v0.19.39 — "+ New" actions: kill existing chat window first so
803
+ # the user gets a clean fresh session.
804
+ if action in ("open:aisb", "open:aisb:new"):
805
+ force = (action == "open:aisb:new")
613
806
  persona = HOME / "Agentik_SSOT" / "agents" / "aisb" / "CLAUDE.md"
614
807
  ctx_dir = tmux._ensure_chat_context_dir(HOME, "aisb-master", persona)
615
808
  tmux.spawn_chat_in_omega(
616
809
  "aisb", ctx_dir=ctx_dir, run_command="claude",
617
- force_replace=False,
810
+ force_replace=force,
618
811
  )
619
812
  subprocess.run(["tmux", "select-window", "-t", "Omega:aisb"])
620
813
  continue
621
- if action == "open:hermes":
814
+ if action in ("open:hermes", "open:hermes:new"):
815
+ force = (action == "open:hermes:new")
622
816
  persona = HOME / "Agentik_SSOT" / "docs" / "LAYERS.md"
623
817
  ctx_dir = tmux._ensure_chat_context_dir(HOME, "hermes", persona)
624
818
  try:
@@ -632,10 +826,18 @@ def _arrow_menu() -> int:
632
826
  if api_key else "claude")
633
827
  tmux.spawn_chat_in_omega(
634
828
  "hermes", ctx_dir=ctx_dir, run_command=run_cmd,
635
- force_replace=False,
829
+ force_replace=force,
636
830
  )
637
831
  subprocess.run(["tmux", "select-window", "-t", "Omega:hermes"])
638
832
  continue
833
+ if action == "audit:run":
834
+ audit_id = _prompt("audit id (codeaudit, uiuxaudit, flowaudit, ...)")
835
+ if audit_id:
836
+ _run_inline([OMEGA_BIN, "audit", audit_id])
837
+ continue
838
+ # (v0.19.31 open:aisb / open:hermes legacy handlers removed in
839
+ # v0.19.39 — the new unified handlers above (which accept both
840
+ # plain and `:new` variants) cover the same surface.)
639
841
  if action == "switch:provider":
640
842
  _run_inline([OMEGA_BIN, "switch"]); continue
641
843
  if action == "genesis:new":
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "omega-engine"
3
- version = "0.19.38"
3
+ version = "0.19.39"
4
4
  description = "The Omega OS orchestration engine — event-sourced, verified-completion agent graphs."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -0,0 +1,142 @@
1
+ """Tests for omega_engine.paperclip_bridge.is_running().
2
+
3
+ Locks in the live status probe contract so the TUI can render ●/○ next to
4
+ the Paperclip menu items. Five detection paths covered:
5
+
6
+ 1. Empty PAPERCLIP_HOME → not running (detection="none")
7
+ 2. Stale pidfile → not running
8
+ 3. Live pidfile (self PID) → running (detection="pidfile")
9
+ 4. No pidfile + open socket → running (detection="port-scan")
10
+ 5. Running case sets url → http://localhost:<port>
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import socket
16
+ import tempfile
17
+ import unittest
18
+ from pathlib import Path
19
+ from unittest import mock
20
+
21
+ from omega_engine import paperclip_bridge as P
22
+
23
+
24
+ def _free_port() -> int:
25
+ """Allocate (and release) an ephemeral TCP port."""
26
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
27
+ s.bind(("127.0.0.1", 0))
28
+ return s.getsockname()[1]
29
+
30
+
31
+ class TestIsRunningDetectionPaths(unittest.TestCase):
32
+ """Each of the three detection paths plus the URL invariant."""
33
+
34
+ def test_no_pidfile_no_port_returns_not_running(self):
35
+ """Empty PAPERCLIP_HOME + closed port → running=False, detection=none."""
36
+ with tempfile.TemporaryDirectory() as tmp:
37
+ with mock.patch.dict(os.environ, {"PAPERCLIP_HOME": tmp}, clear=False):
38
+ # Pick a port nothing is listening on (allocate then release).
39
+ port = _free_port()
40
+ status = P.is_running(port=port)
41
+ self.assertFalse(status.running)
42
+ self.assertEqual(status.detection, "none")
43
+ self.assertIsNone(status.pid)
44
+ self.assertIsNone(status.port)
45
+ self.assertIsNone(status.url)
46
+
47
+ def test_stale_pidfile_returns_not_running(self):
48
+ """Pidfile points at a dead PID → running=False (falls through to port,
49
+ which is also closed in this test)."""
50
+ with tempfile.TemporaryDirectory() as tmp:
51
+ with mock.patch.dict(os.environ, {"PAPERCLIP_HOME": tmp}, clear=False):
52
+ run_dir = Path(tmp) / "run"
53
+ run_dir.mkdir(parents=True, exist_ok=True)
54
+ # 99999999 is virtually guaranteed to be unused on Linux
55
+ # (default PID max is 4194304); we also mock os.kill to be
56
+ # sure across any host's PID-recycling weirdness.
57
+ (run_dir / "dashboard.pid").write_text("99999999\n")
58
+
59
+ def fake_kill(pid: int, sig: int) -> None:
60
+ if pid == 99999999:
61
+ raise ProcessLookupError(pid)
62
+ # delegate to the real os.kill for any other pid
63
+ return os._real_kill(pid, sig) # pragma: no cover
64
+
65
+ # Stash the real kill (in case fake_kill ever delegates).
66
+ os._real_kill = os.kill # type: ignore[attr-defined]
67
+ try:
68
+ port = _free_port()
69
+ with mock.patch("omega_engine.paperclip_bridge.os.kill",
70
+ side_effect=fake_kill):
71
+ status = P.is_running(port=port)
72
+ finally:
73
+ del os._real_kill # type: ignore[attr-defined]
74
+ self.assertFalse(status.running)
75
+ self.assertEqual(status.detection, "none")
76
+ self.assertIsNone(status.pid)
77
+
78
+ def test_live_pidfile_returns_running(self):
79
+ """Pidfile points at this test process (alive) → running=True,
80
+ detection='pidfile', does NOT touch the network."""
81
+ with tempfile.TemporaryDirectory() as tmp:
82
+ with mock.patch.dict(os.environ, {"PAPERCLIP_HOME": tmp}, clear=False):
83
+ run_dir = Path(tmp) / "run"
84
+ run_dir.mkdir(parents=True, exist_ok=True)
85
+ self_pid = os.getpid()
86
+ (run_dir / "dashboard.pid").write_text(f"{self_pid}\n")
87
+ # Patch socket so we'd notice if the code accidentally tried
88
+ # to connect (it shouldn't — pidfile hit returns immediately).
89
+ with mock.patch("omega_engine.paperclip_bridge.socket.socket") \
90
+ as sock_cls:
91
+ status = P.is_running(port=8080)
92
+ sock_cls.assert_not_called()
93
+ self.assertTrue(status.running)
94
+ self.assertEqual(status.detection, "pidfile")
95
+ self.assertEqual(status.pid, self_pid)
96
+ self.assertEqual(status.port, 8080)
97
+
98
+ def test_port_scan_fallback_running(self):
99
+ """No pidfile, but a socket IS listening on the probe port →
100
+ running=True, detection='port-scan', pid=None."""
101
+ # Bind a real listener on an ephemeral port for the duration of
102
+ # the probe; close it at the end.
103
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
104
+ listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
105
+ listener.bind(("127.0.0.1", 0))
106
+ listener.listen(1)
107
+ port = listener.getsockname()[1]
108
+ try:
109
+ with tempfile.TemporaryDirectory() as tmp:
110
+ with mock.patch.dict(os.environ,
111
+ {"PAPERCLIP_HOME": tmp}, clear=False):
112
+ # No pidfile written → pidfile branch skipped, falls
113
+ # through to port scan.
114
+ status = P.is_running(port=port)
115
+ finally:
116
+ listener.close()
117
+ self.assertTrue(status.running)
118
+ self.assertEqual(status.detection, "port-scan")
119
+ self.assertIsNone(status.pid)
120
+ self.assertEqual(status.port, port)
121
+
122
+ def test_url_field_returns_localhost_url_when_running(self):
123
+ """When running=True, status.url is http://localhost:<port>."""
124
+ # Use the pidfile path (cheapest, no socket needed).
125
+ with tempfile.TemporaryDirectory() as tmp:
126
+ with mock.patch.dict(os.environ, {"PAPERCLIP_HOME": tmp}, clear=False):
127
+ run_dir = Path(tmp) / "run"
128
+ run_dir.mkdir(parents=True, exist_ok=True)
129
+ (run_dir / "dashboard.pid").write_text(f"{os.getpid()}\n")
130
+ status = P.is_running(port=8080)
131
+ self.assertTrue(status.running)
132
+ self.assertIsNotNone(status.url)
133
+ # Accept either localhost or 127.0.0.1 form so the implementation
134
+ # can pick whichever it prefers — the contract is "loopback URL".
135
+ self.assertIn(status.url, {
136
+ "http://localhost:8080",
137
+ "http://127.0.0.1:8080",
138
+ })
139
+
140
+
141
+ if __name__ == "__main__":
142
+ unittest.main()