feat: rename rails/ to tools/ and add service tool suites
Rename the `rails/` directory to `tools/` for agent discoverability — agents frequently failed to locate helper scripts due to the non-intuitive directory name. Add backward-compat symlink `rails/ → tools/`. New tool suites: - Authentik: auth-token, user-list, user-create, group-list, app-list, flow-list, admin-status (8 scripts) - Coolify: team-list, project-list, service-list, service-status, deploy, env-set (7 scripts) - Woodpecker: pipeline-list, pipeline-status, pipeline-trigger (3 stubs) - GLPI: session-init, computer-list, ticket-list, ticket-create, user-list (6 scripts) - Health: stack-health.sh — stack-wide connectivity check Infrastructure: - Shared credential loader at tools/_lib/credentials.sh - install.sh creates symlink + chmod on tool scripts - All ~253 rails/ path references updated across 68+ files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
195
tools/orchestrator-matrix/controller/tasks_md_sync.py
Normal file
195
tools/orchestrator-matrix/controller/tasks_md_sync.py
Normal file
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sync docs/TASKS.md rows into .mosaic/orchestrator/tasks.json."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
from typing import Any
|
||||
|
||||
|
||||
def load_json(path: pathlib.Path, default: Any) -> Any:
|
||||
if not path.exists():
|
||||
return default
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def save_json(path: pathlib.Path, data: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
with tmp.open("w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write("\n")
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def split_pipe_row(line: str) -> list[str]:
|
||||
row = line.strip()
|
||||
if row.startswith("|"):
|
||||
row = row[1:]
|
||||
if row.endswith("|"):
|
||||
row = row[:-1]
|
||||
return [c.strip() for c in row.split("|")]
|
||||
|
||||
|
||||
def parse_tasks_markdown(path: pathlib.Path) -> list[dict[str, str]]:
|
||||
if not path.exists():
|
||||
return []
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
header_idx = -1
|
||||
headers: list[str] = []
|
||||
for i, line in enumerate(lines):
|
||||
if "|" not in line:
|
||||
continue
|
||||
cells = [x.lower() for x in split_pipe_row(line)]
|
||||
if "id" in cells and "status" in cells and "description" in cells:
|
||||
header_idx = i
|
||||
headers = cells
|
||||
break
|
||||
if header_idx < 0:
|
||||
return []
|
||||
|
||||
rows: list[dict[str, str]] = []
|
||||
for line in lines[header_idx + 2 :]:
|
||||
if not line.strip().startswith("|"):
|
||||
if rows:
|
||||
break
|
||||
continue
|
||||
cells = split_pipe_row(line)
|
||||
if len(cells) < len(headers):
|
||||
cells += [""] * (len(headers) - len(cells))
|
||||
row = {headers[i]: cells[i] for i in range(len(headers))}
|
||||
task_id = row.get("id", "").strip()
|
||||
if not task_id or task_id.lower() == "id":
|
||||
continue
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def map_status(raw: str) -> str:
|
||||
value = raw.strip().lower()
|
||||
mapping = {
|
||||
"not-started": "pending",
|
||||
"todo": "pending",
|
||||
"pending": "pending",
|
||||
"in-progress": "pending",
|
||||
"needs-qa": "pending",
|
||||
"done": "completed",
|
||||
"completed": "completed",
|
||||
"failed": "failed",
|
||||
}
|
||||
return mapping.get(value, "pending")
|
||||
|
||||
|
||||
def parse_depends(raw: str) -> list[str]:
|
||||
return [x.strip() for x in raw.split(",") if x.strip()]
|
||||
|
||||
|
||||
def build_task(
|
||||
row: dict[str, str],
|
||||
existing: dict[str, Any],
|
||||
runtime_default: str,
|
||||
source_path: str,
|
||||
) -> dict[str, Any]:
|
||||
task_id = row.get("id", "").strip()
|
||||
description = row.get("description", "").strip()
|
||||
issue = row.get("issue", "").strip()
|
||||
repo = row.get("repo", "").strip()
|
||||
branch = row.get("branch", "").strip()
|
||||
depends_on = parse_depends(row.get("depends_on", ""))
|
||||
|
||||
task = dict(existing)
|
||||
task["id"] = task_id
|
||||
task["title"] = description or task_id
|
||||
task["description"] = description
|
||||
task["status"] = map_status(row.get("status", "pending"))
|
||||
task["depends_on"] = depends_on
|
||||
task["runtime"] = str(task.get("runtime") or runtime_default or "codex")
|
||||
task["command"] = str(task.get("command") or "")
|
||||
task["quality_gates"] = task.get("quality_gates") or []
|
||||
metadata = dict(task.get("metadata") or {})
|
||||
metadata.update(
|
||||
{
|
||||
"source": source_path,
|
||||
"issue": issue,
|
||||
"repo": repo,
|
||||
"branch": branch,
|
||||
}
|
||||
)
|
||||
task["metadata"] = metadata
|
||||
return task
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Sync docs/TASKS.md into .mosaic/orchestrator/tasks.json")
|
||||
parser.add_argument("--repo", default=os.getcwd(), help="Repository root (default: cwd)")
|
||||
parser.add_argument("--docs", default="docs/TASKS.md", help="Path to tasks markdown (repo-relative)")
|
||||
parser.add_argument(
|
||||
"--tasks-json",
|
||||
default=".mosaic/orchestrator/tasks.json",
|
||||
help="Path to orchestrator tasks JSON (repo-relative)",
|
||||
)
|
||||
parser.add_argument("--keep-unlisted", action="store_true", help="Retain tasks already in JSON but missing from docs/TASKS.md")
|
||||
parser.add_argument("--apply", action="store_true", help="Write changes (default is dry-run)")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo = pathlib.Path(args.repo).resolve()
|
||||
docs_path = (repo / args.docs).resolve()
|
||||
# Backward compatibility: fall back to legacy lowercase path when default path is absent.
|
||||
if args.docs == "docs/TASKS.md" and not docs_path.exists():
|
||||
legacy_docs_path = (repo / "docs/tasks.md").resolve()
|
||||
if legacy_docs_path.exists():
|
||||
docs_path = legacy_docs_path
|
||||
tasks_path = (repo / args.tasks_json).resolve()
|
||||
config_path = repo / ".mosaic" / "orchestrator" / "config.json"
|
||||
config = load_json(config_path, {})
|
||||
runtime_default = str(config.get("worker", {}).get("runtime") or "codex")
|
||||
|
||||
rows = parse_tasks_markdown(docs_path)
|
||||
try:
|
||||
source_path = str(docs_path.relative_to(repo))
|
||||
except ValueError:
|
||||
source_path = str(docs_path)
|
||||
existing_payload = load_json(tasks_path, {"tasks": []})
|
||||
existing_tasks = existing_payload.get("tasks", [])
|
||||
if not isinstance(existing_tasks, list):
|
||||
existing_tasks = []
|
||||
existing_by_id = {str(t.get("id", "")): t for t in existing_tasks}
|
||||
|
||||
out_tasks: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for row in rows:
|
||||
task_id = row.get("id", "").strip()
|
||||
if not task_id:
|
||||
continue
|
||||
seen.add(task_id)
|
||||
out_tasks.append(
|
||||
build_task(
|
||||
row,
|
||||
existing_by_id.get(task_id, {}),
|
||||
runtime_default,
|
||||
source_path,
|
||||
)
|
||||
)
|
||||
|
||||
if args.keep_unlisted:
|
||||
for task in existing_tasks:
|
||||
task_id = str(task.get("id", ""))
|
||||
if task_id and task_id not in seen:
|
||||
out_tasks.append(task)
|
||||
|
||||
payload = {"tasks": out_tasks}
|
||||
if args.apply:
|
||||
save_json(tasks_path, payload)
|
||||
print(f"[mosaic-orchestrator-sync] wrote {len(out_tasks)} tasks -> {tasks_path}")
|
||||
else:
|
||||
print(f"[mosaic-orchestrator-sync] dry-run: {len(out_tasks)} tasks would be written -> {tasks_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user