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 +166 -0
- bus/main_bus.py +178 -0
- daemon/__init__.py +15 -0
- daemon/api.py +375 -0
- daemon/checks.py +120 -0
- daemon/config.py +98 -0
- daemon/core.py +433 -0
- daemon/log.py +77 -0
- glink_engine-0.5.0.dist-info/METADATA +248 -0
- glink_engine-0.5.0.dist-info/RECORD +14 -0
- glink_engine-0.5.0.dist-info/WHEEL +5 -0
- glink_engine-0.5.0.dist-info/licenses/LICENSE +21 -0
- glink_engine-0.5.0.dist-info/top_level.txt +3 -0
- reporter/reporter.py +355 -0
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
|
+
)
|