hermes-workflow 0.1.3__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.
@@ -0,0 +1,37 @@
1
+ # src/hermes_workflow/__init__.py
2
+ """hermes-workflow: declarative workflow primitive over Hermes Kanban."""
3
+ import pathlib
4
+
5
+ from hermes_workflow.version import PLUGIN_VERSION
6
+ from hermes_workflow import tools, hooks
7
+
8
+
9
+ def register(ctx):
10
+ """Wire every hermes-workflow surface into the Hermes plugin context.
11
+
12
+ Tools + CLI + slash are orchestrator-context mutators (they self-refuse inside
13
+ a worker). Both hooks are bound to ``ctx`` via a closure because Hermes' hook
14
+ invoke does NOT pass ctx (post_tool_call fan-out + pre_tool_call completion gate).
15
+ """
16
+ for name, fn, schema in tools.TOOL_SPECS:
17
+ ctx.register_tool(name=name, toolset="workflow", schema=schema,
18
+ handler=tools.make_tool_handler(ctx, fn))
19
+
20
+ ctx.register_hook("post_tool_call", lambda **kw: hooks.on_tool_done(ctx=ctx, **kw))
21
+ ctx.register_hook("pre_tool_call", lambda **kw: hooks.on_tool_pre(ctx=ctx, **kw))
22
+
23
+ ctx.register_cli_command("workflow", help="manage hermes-workflow runs",
24
+ setup_fn=tools.cli_setup,
25
+ handler_fn=lambda args: tools.cli_dispatch(ctx, args))
26
+ ctx.register_command("workflow", lambda raw: tools.slash_dispatch(ctx, raw),
27
+ description="Manage hermes-workflow runs",
28
+ args_hint="<start|status|validate|reconcile|approve|abandon>")
29
+
30
+ # Bundled skills are shipped in Phase 4; guard the (currently absent) dir so
31
+ # register stays crash-free until then.
32
+ skills_dir = pathlib.Path(__file__).parent / "skills"
33
+ if skills_dir.is_dir():
34
+ for child in sorted(p for p in skills_dir.iterdir() if (p / "SKILL.md").exists()):
35
+ ctx.register_skill(child.name, child / "SKILL.md")
36
+
37
+ getattr(ctx, "log", None) and ctx.log.info("hermes-workflow %s registered", PLUGIN_VERSION)
@@ -0,0 +1,126 @@
1
+ # src/hermes_workflow/board.py
2
+ """Board adapter: two surfaces over the Hermes Kanban.
3
+
4
+ WorkerBoard wraps the worker/hook *model-tool* surface (kanban_create/link/
5
+ comment/show) via ``ctx.dispatch_tool(name, args) -> JSON string``. Every call
6
+ gets an explicit ``board`` injected so it never relies on ambient env routing.
7
+
8
+ HostBoard wraps the orchestrator/CLI *host* surface (kb.* functions) for the
9
+ operations that have NO model tool — archive/unlink (Hermes finding Y) — plus a
10
+ board-wide list/reclaim. It is constructed with an already board-scoped conn.
11
+ """
12
+ import json
13
+
14
+ from hermes_workflow.version import SENTINEL_ROOT_ASSIGNEE
15
+
16
+
17
+ class WorkerBoard:
18
+ """Model-tool surface, reached through a dispatch context.
19
+
20
+ ``ctx`` only needs ``dispatch_tool(tool_name, args, **kwargs) -> str``.
21
+ """
22
+
23
+ def __init__(self, ctx, board):
24
+ self.ctx = ctx
25
+ self.board = board
26
+
27
+ def _call(self, tool, args):
28
+ """Dispatch a kanban tool with an explicit board, return parsed JSON.
29
+
30
+ Immutability: build a NEW args dict — never mutate the caller's.
31
+ """
32
+ payload = {**args, "board": self.board}
33
+ return json.loads(self.ctx.dispatch_tool(tool, payload))
34
+
35
+ def create(self, *, title, parents=(), assignee=SENTINEL_ROOT_ASSIGNEE,
36
+ workspace_kind="scratch", workspace_path=None, body="",
37
+ skills=None, idempotency_key=None):
38
+ # Always pass workspace_kind explicitly: if BOTH workspace_kind and
39
+ # workspace_path are omitted, _handle_create INHERITS the calling
40
+ # worker's workspace (kanban_tools.py:746-794). Passing them keeps the
41
+ # child's workspace deterministic.
42
+ r = self._call("kanban_create", {
43
+ "title": title,
44
+ "assignee": assignee,
45
+ "parents": list(parents) if parents else [],
46
+ "workspace_kind": workspace_kind,
47
+ "workspace_path": workspace_path,
48
+ "body": body,
49
+ "skills": skills,
50
+ "idempotency_key": idempotency_key,
51
+ })
52
+ if not r.get("ok"):
53
+ raise BoardError(f"kanban_create failed: {r}")
54
+ return r["task_id"]
55
+
56
+ def link(self, parent_id, child_id):
57
+ r = self._call("kanban_link", {"parent_id": parent_id, "child_id": child_id})
58
+ if not r.get("ok"):
59
+ raise BoardError(f"kanban_link failed: {r}")
60
+ return r
61
+
62
+ def comment(self, task_id, body):
63
+ r = self._call("kanban_comment", {"task_id": task_id, "body": body})
64
+ if not r.get("ok"):
65
+ raise BoardError(f"kanban_comment failed: {r}")
66
+ return r
67
+
68
+ def complete(self, *, task_id, summary=None):
69
+ r = self._call("kanban_complete", {"task_id": task_id, "summary": summary})
70
+ if not r.get("ok"):
71
+ raise BoardError(f"kanban_complete failed: {r}")
72
+ return r
73
+
74
+ def show(self, task_id):
75
+ """Return a FLAT card dict.
76
+
77
+ kanban_show (kanban_tools.py:339-412) returns a NESTED shape:
78
+ ``{"task": {...}, "parents": [...], "children": [...], "runs": [...],
79
+ "comments": [...], ...}`` — NOT an _ok envelope. Downstream Phase-2 code
80
+ (RunView/materializer/hooks) wants a single flat card, so this is the
81
+ one place we normalize: merge the ``task`` sub-dict up to the top level
82
+ and re-attach the link/run/comment lists.
83
+ """
84
+ raw = self._call("kanban_show", {"task_id": task_id})
85
+ if "task" not in raw:
86
+ # Error envelope ({"error": ...}/{"ok": false}) or a missing card.
87
+ raise BoardError(f"kanban_show returned no task for {task_id!r}: {raw}")
88
+ task = raw["task"]
89
+ return {
90
+ **task,
91
+ "parents": raw.get("parents", []),
92
+ "children": raw.get("children", []),
93
+ "runs": raw.get("runs", []),
94
+ "comments": raw.get("comments", []),
95
+ "events": raw.get("events", []),
96
+ }
97
+
98
+
99
+ class HostBoard:
100
+ """Host kb.* surface for orchestrator/CLI-only operations.
101
+
102
+ The ``conn`` is already board-scoped, so kb.* calls take no ``board=``
103
+ kwarg. ``board`` is retained for identity/logging.
104
+ """
105
+
106
+ def __init__(self, kb, conn, board):
107
+ self.kb = kb
108
+ self.conn = conn
109
+ self.board = board
110
+
111
+ def list(self):
112
+ """Board-wide sweep (the conn is already board-scoped)."""
113
+ return self.kb.list_tasks(self.conn)
114
+
115
+ def archive(self, task_id):
116
+ return self.kb.archive_task(self.conn, task_id)
117
+
118
+ def unlink(self, parent_id, child_id):
119
+ return self.kb.unlink_tasks(self.conn, parent_id, child_id)
120
+
121
+ def reclaim(self, task_id):
122
+ return self.kb.reclaim_task(self.conn, task_id)
123
+
124
+
125
+ class BoardError(RuntimeError):
126
+ """Raised when a kanban model-tool call returns a non-ok / error payload."""
File without changes
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from hermes_workflow.engine.interpolate import interpolate
4
+ from hermes_workflow.engine.model import Template, Stage
5
+ from hermes_workflow.lanes.presets import lane_skill
6
+
7
+ GATE_ASSIGNEE = "_workflow_gate" # mirrors version.SENTINEL_GATE_ASSIGNEE
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Identity:
12
+ stage_id: str
13
+ fan_index: int
14
+ attempt: int = 0
15
+
16
+
17
+ @dataclass
18
+ class CardSpec:
19
+ identity: Identity
20
+ title: str
21
+ body: str
22
+ assignee: str
23
+ workspace: str
24
+ parent_identities: list # list[Identity]; NOTE: list (a test asserts == [Identity(...)])
25
+ skills: list = field(default_factory=list)
26
+ gate: bool = False
27
+
28
+
29
+ def _expanded(stage: Stage, completed: dict):
30
+ """The source stage's emitted item list, or None if the source isn't done yet."""
31
+ src = completed.get(stage.expand.over_stage)
32
+ if src is None:
33
+ return None # source not done -> stage not materializable
34
+ return list(src.get(stage.expand.over_key, []))
35
+
36
+
37
+ def _parents_for(stage: Stage, t: Template, completed: dict) -> list:
38
+ """Parent identities: for each needed stage, every instance that exists/should exist."""
39
+ parents = []
40
+ for dep in stage.needs:
41
+ dep_stage = t.stage(dep)
42
+ if dep_stage.expand:
43
+ items = _expanded(dep_stage, completed)
44
+ if items is None:
45
+ continue
46
+ if items:
47
+ parents += [Identity(dep, i, 0) for i in range(len(items))]
48
+ else:
49
+ # empty fan-out: gate the consumer on the fan-out SOURCE instead
50
+ parents.append(Identity(dep_stage.expand.over_stage, 0, 0))
51
+ else:
52
+ parents.append(Identity(dep, 0, 0))
53
+ return parents
54
+
55
+
56
+ def _materializable(stage: Stage, t: Template, completed: dict) -> bool:
57
+ """A stage is materializable once every fan-out it depends on (incl. its own) has a done source."""
58
+ for dep in stage.needs:
59
+ dep_stage = t.stage(dep)
60
+ if dep_stage.expand and _expanded(dep_stage, completed) is None:
61
+ return False # waiting on the fan-out source to complete
62
+ if stage.expand and _expanded(stage, completed) is None:
63
+ return False
64
+ return True
65
+
66
+
67
+ def cards_for_run(t: Template, params: dict, bindings: dict, *, completed: dict, existing: set) -> list:
68
+ specs: list = []
69
+ for stage in t.stages:
70
+ if not _materializable(stage, t, completed):
71
+ continue
72
+ instances = _expanded(stage, completed) if stage.expand else [None]
73
+ if instances is None:
74
+ continue
75
+ for idx, item in enumerate(instances):
76
+ ident = Identity(stage.id, idx if stage.expand else 0, 0)
77
+ if ident in existing:
78
+ continue
79
+ evars = {stage.expand.as_var: item} if (stage.expand and item is not None) else {}
80
+ assignee = bindings.get(stage.role, GATE_ASSIGNEE) if stage.role else GATE_ASSIGNEE
81
+ lane = t.roles[stage.role].lane if stage.role else None
82
+ specs.append(CardSpec(
83
+ identity=ident,
84
+ title=interpolate(stage.title, params=params, expand_vars=evars),
85
+ body=interpolate(stage.body, params=params, expand_vars=evars),
86
+ assignee=assignee,
87
+ workspace=interpolate(stage.workspace, params=params, expand_vars={}),
88
+ parent_identities=_parents_for(stage, t, completed),
89
+ skills=([lane_skill(lane)] if lane and lane_skill(lane) else []),
90
+ gate=stage.gate == "human",
91
+ ))
92
+ return specs
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+ import re
3
+
4
+ _TOKEN = re.compile(r"\$\{([^}]+)\}")
5
+
6
+
7
+ class InterpolationError(ValueError):
8
+ pass
9
+
10
+
11
+ def interpolate(s: str, *, params: dict, expand_vars: dict) -> str:
12
+ """Substitute only ${params.*} and ${<expand-var>.*}; raise on any unknown ref/namespace."""
13
+ def repl(m):
14
+ ref = m.group(1).strip()
15
+ ns, _, rest = ref.partition(".")
16
+ if ns == "params":
17
+ if rest not in params:
18
+ raise InterpolationError(f"unknown param {rest!r}")
19
+ return str(params[rest])
20
+ if ns in expand_vars:
21
+ obj = expand_vars[ns]
22
+ if rest not in obj:
23
+ raise InterpolationError(f"unknown field {rest!r} on {ns!r}")
24
+ return str(obj[rest])
25
+ raise InterpolationError(f"unknown reference namespace {ns!r}")
26
+ return _TOKEN.sub(repl, s)
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class Param:
7
+ name: str
8
+ type: str
9
+ required: bool = False
10
+ default: object = None
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Role:
15
+ name: str
16
+ lane: str # "profile" | "codex"
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ExpandSpec:
21
+ over_stage: str
22
+ over_key: str
23
+ as_var: str
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ExpandOut:
28
+ key: str
29
+ item: dict # field_name -> type string
30
+ max: int = 50
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Stage:
35
+ id: str
36
+ role: str | None = None
37
+ title: str = ""
38
+ body: str = ""
39
+ needs: tuple = ()
40
+ expand: ExpandSpec | None = None
41
+ expand_out: ExpandOut | None = None
42
+ gate: str | None = None # "human" or None
43
+ workspace: str = "scratch" # scratch | dir:<p> | worktree:<p>
44
+ verify_raw: object = None # raw `verify:` block, captured so Task 8 can REJECT it (0.2.x)
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Template:
49
+ name: str
50
+ version: str
51
+ description: str
52
+ params: dict # name -> Param
53
+ roles: dict # name -> Role
54
+ stages: tuple # tuple[Stage]
55
+
56
+ def stage(self, sid: str) -> Stage:
57
+ for s in self.stages:
58
+ if s.id == sid:
59
+ return s
60
+ raise KeyError(sid)
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+ import base64
3
+ import json
4
+ from dataclasses import dataclass, asdict
5
+
6
+ _SENTINEL_OPEN = "<!--hermes-workflow:sentinel "
7
+ _SENTINEL_CLOSE = "-->"
8
+ _SNAPSHOT_OPEN = "<!--hermes-workflow:snapshot "
9
+
10
+
11
+ def _encode(obj) -> str:
12
+ # Base64 alphabet is [A-Za-z0-9+/=], which cannot contain '-->', so the
13
+ # first '-->' after the open marker is always the genuine terminator.
14
+ return base64.b64encode(json.dumps(obj).encode("utf-8")).decode("ascii")
15
+
16
+
17
+ def _decode(raw: str):
18
+ return json.loads(base64.b64decode(raw.encode("ascii"), validate=True).decode("utf-8"))
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Sentinel:
23
+ workflow_root: str
24
+ stage_id: str
25
+ fan_index: int
26
+ attempt: int
27
+ template_id: str
28
+ template_version: str
29
+ plugin_version: str
30
+ schema_version: str
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class CompiledSnapshot:
35
+ template_yaml: str
36
+ params: dict
37
+ bindings: dict
38
+ plugin_version: str
39
+ schema_version: str
40
+ base_ref: str | None = None
41
+
42
+
43
+ def embed_sentinel(body: str, s: Sentinel) -> str:
44
+ return f"{_SENTINEL_OPEN}{_encode(asdict(s))}{_SENTINEL_CLOSE}\n{body}"
45
+
46
+
47
+ def extract_sentinel(body: str) -> Sentinel | None:
48
+ i = body.find(_SENTINEL_OPEN)
49
+ if i < 0:
50
+ return None
51
+ j = body.find(_SENTINEL_CLOSE, i)
52
+ if j < 0:
53
+ return None
54
+ raw = body[i + len(_SENTINEL_OPEN):j].strip()
55
+ try:
56
+ return Sentinel(**_decode(raw))
57
+ except Exception:
58
+ return None
59
+
60
+
61
+ def build_root_body(template_yaml: str, params: dict, bindings: dict,
62
+ plugin_version: str, schema_version: str, base_ref=None) -> str:
63
+ payload = {"template_yaml": template_yaml, "params": params, "bindings": bindings,
64
+ "plugin_version": plugin_version, "schema_version": schema_version,
65
+ "base_ref": base_ref}
66
+ return f"{_SNAPSHOT_OPEN}{_encode(payload)}{_SENTINEL_CLOSE}\n# hermes-workflow run\n"
67
+
68
+
69
+ def parse_root_body(body: str) -> CompiledSnapshot:
70
+ # RAISES by design on a malformed/non-snapshot body: callers
71
+ # (tools.py::_root_snapshot, hooks.py) wrap this in try/except and rely on
72
+ # it raising. A corrupt root snapshot must wedge loudly (fail-closed at the
73
+ # veto / WorkflowError at reconcile), never fail soft. Do NOT add a
74
+ # swallowing try/except here.
75
+ i = body.find(_SNAPSHOT_OPEN)
76
+ j = body.find(_SENTINEL_CLOSE, i)
77
+ payload = _decode(body[i + len(_SNAPSHOT_OPEN):j].strip())
78
+ return CompiledSnapshot(**payload)
79
+
80
+
81
+ def is_version_compatible(snapshot, supported) -> bool:
82
+ """Total: never raises. Returns False for anything it cannot positively confirm.
83
+
84
+ (Spike 1: a raising gate fails OPEN at the host, so we must fail CLOSED by
85
+ returning False.) The try/except wraps both ``set(supported)`` and the
86
+ membership test so a bad ``supported`` (e.g. None) yields False, not an error.
87
+ """
88
+ if isinstance(supported, (str, bytes)):
89
+ return False # a bare string would set()-split per character -> false-accept
90
+ try:
91
+ return getattr(snapshot, "schema_version", None) in set(supported)
92
+ except Exception:
93
+ return False
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+
4
+ _STATUS_RANK = {"done": 5, "running": 4, "ready": 3, "todo": 2, "blocked": 1, "archived": 0}
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class CardRow:
9
+ card_id: str
10
+ status: str
11
+ latest_run_id: int | None = None
12
+ created_at: float = 0.0
13
+
14
+
15
+ def pick_winner(rows: list[CardRow]) -> CardRow:
16
+ """Deterministic winner among duplicates sharing one sentinel identity.
17
+ A 'done' duplicate wins unconditionally (status rank); tiebreak by
18
+ latest_run_id -> created_at -> min(card_id). Falls back to status rank
19
+ when none is 'done'.
20
+ """
21
+ if not rows:
22
+ raise ValueError("pick_winner: no rows")
23
+ # Sort key for min(): negate the higher-is-better fields (status rank, run_id,
24
+ # created_at) so the largest wins; card_id stays plain so the smallest (min) wins.
25
+ return min(rows, key=lambda r: (
26
+ -_STATUS_RANK.get(r.status, 0),
27
+ -(r.latest_run_id if r.latest_run_id is not None else -1),
28
+ -r.created_at,
29
+ r.card_id,
30
+ ))
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+ import re
3
+ import yaml
4
+ from hermes_workflow.engine.model import Param, Role, ExpandSpec, ExpandOut, Stage, Template
5
+
6
+ _WS_TOKEN = re.compile(r"\$\{([^}]+)\}")
7
+
8
+
9
+ class TemplateError(ValueError):
10
+ pass
11
+
12
+
13
+ def parse_template(text: str) -> Template:
14
+ try:
15
+ raw = yaml.safe_load(text)
16
+ except yaml.YAMLError as e:
17
+ raise TemplateError(f"invalid YAML: {e}") from e
18
+ if not isinstance(raw, dict):
19
+ raise TemplateError("template must be a YAML mapping")
20
+ params = {n: Param(n, p.get("type", "string"), bool(p.get("required", False)), p.get("default"))
21
+ for n, p in (raw.get("params") or {}).items()}
22
+ roles = {n: Role(n, r.get("lane", "profile")) for n, r in (raw.get("roles") or {}).items()}
23
+ stages = []
24
+ for s in raw.get("stages") or []:
25
+ exp = s.get("expand")
26
+ expand = None
27
+ if exp:
28
+ over = exp["over"]
29
+ if "." not in over:
30
+ raise TemplateError(f"stage {s.get('id')!r}: expand.over must be '<stage>.<key>', got {over!r}")
31
+ st, key = over.split(".", 1) # "<stage>.<key>"
32
+ expand = ExpandSpec(st, key, exp["as"])
33
+ eo = s.get("expand_out")
34
+ expand_out = ExpandOut(eo["key"], eo.get("item", {}), int(eo.get("max", 50))) if eo else None
35
+ stages.append(Stage(
36
+ id=s["id"], role=s.get("role"), title=s.get("title", ""), body=s.get("body", ""),
37
+ needs=tuple(s.get("needs", [])), expand=expand, expand_out=expand_out,
38
+ gate=s.get("gate"), workspace=s.get("workspace", "scratch"),
39
+ verify_raw=s.get("verify"),
40
+ ))
41
+ return Template(raw["name"], str(raw["version"]), raw.get("description", ""),
42
+ params, roles, tuple(stages))
43
+
44
+
45
+ def validate_template(t: Template) -> None:
46
+ """Deterministic gatekeeper. Raises TemplateError on the first rule violation."""
47
+ ids = [s.id for s in t.stages]
48
+ dupes = sorted({sid for sid in ids if ids.count(sid) > 1})
49
+ if dupes:
50
+ raise TemplateError(f"duplicate stage id(s): {', '.join(dupes)}")
51
+ id_set = set(ids)
52
+ expand_sources = {s.expand.over_stage for s in t.stages if s.expand}
53
+
54
+ for s in t.stages:
55
+ # gate vs role
56
+ if s.gate is not None:
57
+ if s.gate != "human":
58
+ raise TemplateError(
59
+ f"stage {s.id}: only 'gate: human' is supported in 0.1.0, got {s.gate!r}")
60
+ if s.role is not None:
61
+ raise TemplateError(f"stage {s.id}: a gate stage must not declare a role")
62
+ elif s.role is None:
63
+ raise TemplateError(f"stage {s.id}: non-gate stage needs a role")
64
+ if s.role is not None and s.role not in t.roles:
65
+ raise TemplateError(f"stage {s.id}: unknown role {s.role!r}")
66
+
67
+ for dep in s.needs:
68
+ if dep not in id_set:
69
+ raise TemplateError(f"stage {s.id}: needs unknown stage {dep!r}")
70
+
71
+ if s.verify_raw is not None:
72
+ raise TemplateError(f"stage {s.id}: verify.command/retry is deferred to 0.2.x")
73
+
74
+ if s.expand:
75
+ src = t.stage(s.expand.over_stage) if s.expand.over_stage in id_set else None
76
+ if src is None:
77
+ raise TemplateError(
78
+ f"stage {s.id}: expand.over references unknown stage {s.expand.over_stage!r}")
79
+ if not src.expand_out or src.expand_out.key != s.expand.over_key:
80
+ raise TemplateError(
81
+ f"stage {s.id}: expand.over key must match source expand_out.key")
82
+ if s.id in expand_sources:
83
+ raise TemplateError(f"stage {s.id}: nested expand is forbidden in 0.1.0")
84
+
85
+ for tok in _WS_TOKEN.findall(s.workspace):
86
+ if not tok.strip().startswith("params."):
87
+ raise TemplateError(
88
+ f"stage {s.id}: workspace may only use ${{params.*}}, got {tok!r}")
89
+ if s.expand and s.workspace.split(":", 1)[0] == "scratch":
90
+ raise TemplateError(f"stage {s.id}: fan-out stages must not use scratch workspace")
91
+
92
+ _check_cycle(t)
93
+
94
+
95
+ def _check_cycle(t: Template) -> None:
96
+ graph = {s.id: set(s.needs) for s in t.stages}
97
+ visiting, done = set(), set()
98
+
99
+ def dfs(n):
100
+ if n in done:
101
+ return
102
+ if n in visiting:
103
+ raise TemplateError(f"dependency cycle at stage {n!r}")
104
+ visiting.add(n)
105
+ for m in graph.get(n, ()):
106
+ dfs(m)
107
+ visiting.discard(n)
108
+ done.add(n)
109
+
110
+ for n in graph:
111
+ dfs(n)