glink-engine 0.5.0__py3-none-any.whl

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.
bus/agent_client.py ADDED
@@ -0,0 +1,166 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """
3
+ agent_client — Glink shared Agent communication & workflow loading module
4
+
5
+ Shared by: glink.py (one-shot) and glink-daemon.py (checkpoint-resume daemon).
6
+
7
+ Exports:
8
+ - AGENT_PORTS: Agent name → HTTP port mapping
9
+ - call_agent(): HTTP call to agent's /ask endpoint
10
+ - load_workflow(): Load YAML workflow from workflows/ or bus/projects/
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import re
16
+ import sys
17
+ import urllib.error
18
+ import urllib.request
19
+ import time
20
+
21
+ # ── Agent port mapping (single source of truth) ────────────
22
+ # One port can have multiple aliases
23
+ AGENT_PORTS = {
24
+ "agent-1": 8420,
25
+ "agent-2": 8431,
26
+ "agent-3": 8432,
27
+ "agent-4": 8434,
28
+ "agent-5": 8435,
29
+ "agent-6": 8436,
30
+ }
31
+
32
+ # ── Project name sanitizer (prevents path traversal) ────
33
+ _PROJECT_RE = re.compile(r"^[\w\-]+$")
34
+
35
+
36
+ def _sanitize_project_name(name: str) -> str:
37
+ """Filter project name — only alphanumeric, underscore, hyphen allowed."""
38
+ safe = _PROJECT_RE.sub("", name)
39
+ if safe != name:
40
+ safe = re.sub(r"[^\w\-]", "", name)
41
+ return safe.strip().lower() or "unnamed"
42
+
43
+
44
+ # ── HTTP call to agent ─────────────────────────────────────
45
+
46
+
47
+ def call_agent(
48
+ agent: str,
49
+ task: str,
50
+ port: int | None = None,
51
+ timeout: int = 600,
52
+ parse_reply: bool = True,
53
+ ) -> dict:
54
+ """Call an agent's /ask endpoint via HTTP.
55
+
56
+ Args:
57
+ agent: Agent name (e.g. "agent-1", "Forge")
58
+ task: Task description for the agent
59
+ port: Explicit port; falls back to AGENT_PORTS lookup
60
+ timeout: Request timeout in seconds
61
+ parse_reply: If True, try to parse JSON and extract 'reply' field.
62
+ If False, return raw response text.
63
+
64
+ Returns:
65
+ {"status": "ok", "output": "<reply or first 500 chars>"}
66
+ {"status": "failed", "error": "<description>"}
67
+ """
68
+ p = port or AGENT_PORTS.get(agent, 8420)
69
+ url = f"http://127.0.0.1:{p}/ask"
70
+ payload = json.dumps({"message": task}).encode()
71
+ start = time.time()
72
+ try:
73
+ req = urllib.request.Request(
74
+ url,
75
+ data=payload,
76
+ headers={"Content-Type": "application/json"},
77
+ method="POST",
78
+ )
79
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
80
+ body = resp.read().decode()
81
+ dur = round(time.time() - start, 1)
82
+ if parse_reply:
83
+ try:
84
+ data = json.loads(body)
85
+ reply = data.get(
86
+ "reply", data.get("response", data.get("output", body[:500]))
87
+ )
88
+ return {"status": "ok", "output": reply[:2000], "duration": dur}
89
+ except json.JSONDecodeError:
90
+ return {"status": "ok", "output": body[:2000], "duration": dur}
91
+ return {"status": "ok", "output": body[:2000], "duration": dur}
92
+ except urllib.error.HTTPError as e:
93
+ dur = round(time.time() - start, 1)
94
+ return {
95
+ "status": "failed",
96
+ "error": f"HTTP {e.code}: {e.reason}",
97
+ "duration": dur,
98
+ }
99
+ except urllib.error.URLError:
100
+ dur = round(time.time() - start, 1)
101
+ return {
102
+ "status": "failed",
103
+ "error": f"Connection refused: {agent}(:{p})",
104
+ "duration": dur,
105
+ }
106
+ except Exception as e:
107
+ dur = round(time.time() - start, 1)
108
+ return {"status": "failed", "error": str(e), "duration": dur}
109
+
110
+
111
+ # ── Workflow loading ───────────────────────────────────────
112
+
113
+
114
+ def load_workflow(project_name: str, base_dir: str | None = None) -> dict:
115
+ """Load a workflow YAML. Searches workflows/ first, then bus/projects/.
116
+
117
+ Args:
118
+ project_name: Project name (sanitized by _sanitize_project_name)
119
+ base_dir: Glink root directory; defaults to parent of this file
120
+
121
+ Returns:
122
+ Parsed workflow dict
123
+
124
+ Raises:
125
+ SystemExit(1): Workflow file not found
126
+ """
127
+ if base_dir is None:
128
+ base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
129
+ safe_name = _sanitize_project_name(project_name)
130
+
131
+ # Search: workflows/<name>.yaml, workflows/<name>.yml, bus/projects/<name>.yaml
132
+ search_paths = []
133
+ base_wf = os.path.join(base_dir, "workflows")
134
+ for ext in (".yaml", ".yml"):
135
+ search_paths.append(os.path.join(base_wf, f"{safe_name}{ext}"))
136
+ search_paths.append(os.path.join(base_wf, f"{project_name}{ext}"))
137
+ bus_dir = os.path.join(base_dir, "bus", "projects")
138
+ search_paths.append(os.path.join(bus_dir, f"{safe_name}.yaml"))
139
+
140
+ for p in search_paths:
141
+ if os.path.exists(p):
142
+ try:
143
+ import yaml
144
+ except ImportError:
145
+ import subprocess
146
+
147
+ subprocess.check_call(
148
+ [
149
+ sys.executable,
150
+ "-m",
151
+ "pip",
152
+ "install",
153
+ "pyyaml",
154
+ "-q",
155
+ "--quiet",
156
+ "--quiet",
157
+ ],
158
+ stdout=subprocess.DEVNULL,
159
+ stderr=subprocess.DEVNULL,
160
+ )
161
+ import yaml # noqa: F811
162
+ with open(p) as f:
163
+ return yaml.safe_load(f)
164
+
165
+ print(f"❌ Workflow not found: {safe_name}", file=sys.stderr)
166
+ sys.exit(1)
bus/main_bus.py ADDED
@@ -0,0 +1,178 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """
3
+ Main Bus — Shared project timeline
4
+
5
+ All agents read/write project-level shared memory through this module.
6
+
7
+ Storage: JSONL (one event per line)
8
+ Event types:
9
+ - task.created: Task created
10
+ - task.assigned: Task assigned to an agent
11
+ - task.started: Task execution started
12
+ - task.completed: Task completed
13
+ - task.failed: Task failed
14
+ - task.log: Execution log
15
+ - project.update: Project status update
16
+ """
17
+
18
+ import fcntl
19
+ import json
20
+ import os
21
+ import sys
22
+
23
+ BUS_ROOT = os.path.dirname(os.path.abspath(__file__))
24
+ PROJECTS_DIR = os.path.join(BUS_ROOT, "projects")
25
+
26
+ # Project name whitelist: only alphanumeric, underscore, hyphen (prevents path traversal)
27
+ _PROJECT_RE = __import__("re").compile(r"^[\w\-]+$")
28
+
29
+
30
+ def _sanitize(project_name: str) -> str:
31
+ """Filter project name, prevent path traversal (only [\\w\\-] allowed)."""
32
+ if not _PROJECT_RE.match(project_name):
33
+ import re as _re
34
+
35
+ return _re.sub(r"[^\w\-]", "", project_name).strip().lower() or "unnamed"
36
+ return project_name.strip().lower()
37
+
38
+
39
+ def _bus_path(project_name: str) -> str:
40
+ """Get bus file path for project."""
41
+ safe = _sanitize(project_name)
42
+ os.makedirs(PROJECTS_DIR, exist_ok=True)
43
+ return os.path.join(PROJECTS_DIR, f"{safe}.jsonl")
44
+
45
+
46
+ def write(
47
+ project_name: str, event_type: str, agent: str, data: dict, stage: str = ""
48
+ ) -> bool:
49
+ """Write an event to Main Bus (file-locked, concurrent-safe)."""
50
+ import time as _time
51
+
52
+ path = _bus_path(project_name)
53
+ ev = {
54
+ "type": event_type,
55
+ "agent": agent,
56
+ "data": data,
57
+ "stage": stage,
58
+ "ts": _time.time(),
59
+ "iso": __import__("datetime").datetime.now().isoformat(),
60
+ }
61
+ try:
62
+ with open(path, "a") as f:
63
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX)
64
+ f.write(json.dumps(ev, ensure_ascii=False) + "\n")
65
+ f.flush()
66
+ os.fsync(f.fileno())
67
+ return True
68
+ except Exception:
69
+ # Write failure does not propagate — logged to stderr, won't crash the caller
70
+ import traceback
71
+
72
+ print(f"[MainBus] Write failed: {traceback.format_exc()}", file=sys.stderr)
73
+ return False
74
+
75
+
76
+ def read(project_name: str, limit: int = 100) -> list[dict]:
77
+ """Read recent events from Main Bus."""
78
+ # BUG-02: Negative or zero n parameter validation (Forge 2026-05-25)
79
+ if limit <= 0:
80
+ return []
81
+ path = _bus_path(project_name)
82
+ if not os.path.exists(path):
83
+ return []
84
+ events = []
85
+ with open(path) as f:
86
+ for line in f:
87
+ line = line.strip()
88
+ if line:
89
+ try:
90
+ events.append(json.loads(line))
91
+ except json.JSONDecodeError:
92
+ continue
93
+ return events[-limit:]
94
+
95
+
96
+ def latest(project_name: str, event_type: str | None = None) -> dict | None:
97
+ """Get the latest event, optionally filtered by type."""
98
+ events = read(project_name)
99
+ if event_type:
100
+ for ev in reversed(events):
101
+ if ev["type"] == event_type:
102
+ return ev
103
+ return None
104
+ return events[-1] if events else None
105
+
106
+
107
+ def status(project_name: str) -> dict:
108
+ """Get current project status summary."""
109
+ events = read(project_name, limit=500)
110
+ total = len(events)
111
+ stats = {"completed": 0, "failed": 0, "started": 0, "others": 0}
112
+ agents = set()
113
+ stages = set()
114
+ for ev in events:
115
+ et = ev.get("type", "")
116
+ if et == "task.completed":
117
+ stats["completed"] += 1
118
+ elif et == "task.failed":
119
+ stats["failed"] += 1
120
+ elif et == "task.started":
121
+ stats["started"] += 1
122
+ else:
123
+ stats["others"] += 1
124
+ agents.add(ev.get("agent", ""))
125
+ if ev.get("stage"):
126
+ stages.add(ev.get("stage", ""))
127
+ return {
128
+ "project": project_name,
129
+ "total_events": total,
130
+ "tasks_completed": stats["completed"],
131
+ "tasks_failed": stats["failed"],
132
+ "tasks_started": stats["started"],
133
+ "agents_involved": sorted(a for a in agents if a),
134
+ "stages": sorted(s for s in stages if s),
135
+ }
136
+
137
+
138
+ if __name__ == "__main__":
139
+ # CLI entry
140
+ cmd = sys.argv[1] if len(sys.argv) > 1 else ""
141
+ if not cmd:
142
+ print(f"Usage: python3 {sys.argv[0]} <command> [args...]")
143
+ print("Commands: write, read, status, latest")
144
+ sys.exit(0)
145
+
146
+ proj = sys.argv[2] if len(sys.argv) > 2 else "hello-world"
147
+
148
+ if cmd == "write":
149
+ etype = sys.argv[3] if len(sys.argv) > 3 else "task.log"
150
+ agent = sys.argv[4] if len(sys.argv) > 4 else "cli"
151
+ data_str = sys.argv[5] if len(sys.argv) > 5 else '{"msg":"test"}'
152
+ try:
153
+ data = json.loads(data_str)
154
+ except json.JSONDecodeError:
155
+ data = {"msg": data_str}
156
+ write(proj, etype, agent, data)
157
+ print(f"Written: {etype}/{agent} to {proj}")
158
+
159
+ elif cmd == "read":
160
+ limit = int(sys.argv[3]) if len(sys.argv) > 3 else 20
161
+ evs = read(proj, limit)
162
+ for ev in evs:
163
+ print(json.dumps(ev, ensure_ascii=False)[:200])
164
+
165
+ elif cmd == "status":
166
+ s = status(proj)
167
+ print(f"Project: {s['project']}")
168
+ print(f"Total events: {s['total_events']}")
169
+ print("Tasks: created/started/completed/failed")
170
+ print(f"Agents: {', '.join(s['agents_involved'])}")
171
+ print(f"Stages: {', '.join(s['stages'])}")
172
+
173
+ elif cmd == "latest":
174
+ ev = latest(proj)
175
+ if ev:
176
+ print(json.dumps(ev, ensure_ascii=False)[:300])
177
+ else:
178
+ print("No events")
daemon/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Glink Daemon package — entry aggregator module
3
+ from .api import set_project, start_api_server # noqa: F401
4
+ from .checks import cleanup_pidfile, ensure_pid, self_restart # noqa: F401
5
+ from .core import load_workflow, run_workflow # noqa: F401
6
+ from .log import ( # noqa: F401
7
+ get_reporter,
8
+ log,
9
+ log_err,
10
+ log_ok,
11
+ log_retry,
12
+ log_step,
13
+ log_warn,
14
+ send_alert,
15
+ )