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.
- hermes_workflow/__init__.py +37 -0
- hermes_workflow/board.py +126 -0
- hermes_workflow/engine/__init__.py +0 -0
- hermes_workflow/engine/graph.py +92 -0
- hermes_workflow/engine/interpolate.py +26 -0
- hermes_workflow/engine/model.py +60 -0
- hermes_workflow/engine/provenance.py +93 -0
- hermes_workflow/engine/reconcile.py +30 -0
- hermes_workflow/engine/template.py +111 -0
- hermes_workflow/hooks.py +137 -0
- hermes_workflow/lanes/__init__.py +0 -0
- hermes_workflow/lanes/presets.py +19 -0
- hermes_workflow/materialize.py +144 -0
- hermes_workflow/plugin.yaml +13 -0
- hermes_workflow/preflight.py +123 -0
- hermes_workflow/runview.py +100 -0
- hermes_workflow/skills/workflow-author/SKILL.md +176 -0
- hermes_workflow/skills/workflow-orchestrator/SKILL.md +165 -0
- hermes_workflow/sweep.py +30 -0
- hermes_workflow/tools.py +672 -0
- hermes_workflow/version.py +6 -0
- hermes_workflow/veto.py +85 -0
- hermes_workflow/worktree.py +74 -0
- hermes_workflow-0.1.3.dist-info/METADATA +192 -0
- hermes_workflow-0.1.3.dist-info/RECORD +29 -0
- hermes_workflow-0.1.3.dist-info/WHEEL +5 -0
- hermes_workflow-0.1.3.dist-info/entry_points.txt +2 -0
- hermes_workflow-0.1.3.dist-info/licenses/LICENSE +21 -0
- hermes_workflow-0.1.3.dist-info/top_level.txt +1 -0
|
@@ -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)
|
hermes_workflow/board.py
ADDED
|
@@ -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)
|