diff --git a/README.md b/README.md index 1bc14a8..e9932be 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,8 @@ cat > .hermes/workflows.registry.json <<'JSON' "default": "workflows.sqlite" }, "workflows": { - "reviewable-draft": { - "workflow_ref": "hermes_workflows.examples.reviewable_draft:reviewable_draft_workflow", + "typed-quickstart": { + "workflow_ref": "hermes_workflows.examples.install_smoke:release_note_workflow", "db": "default" } } @@ -44,13 +44,13 @@ cat > .hermes/workflows.registry.json <<'JSON' JSON ``` -Run the installed facade-first demo and then run the Workflow Runner v2 in the foreground until it drains runnable commands and the workflow reaches the typed Review Queue request: +Run the installed, fully typed quickstart and then run the Workflow Runner v2 in the foreground until it drains runnable commands and the workflow reaches the typed Review Queue request: ```bash -hermes-workflows run reviewable-draft \ +hermes-workflows run typed-quickstart \ --config .hermes/workflows.registry.json \ - --id wf_reviewable_draft_quickstart \ - --input-json '{"topic":"Hermes Workflows launch"}' + --id wf_typed_quickstart \ + --input-json '{"change":"Expose typed workflow contracts."}' hermes-workflows runner run \ --config .hermes/workflows.registry.json \ @@ -60,13 +60,37 @@ hermes-workflows runner run \ hermes-workflows status \ --db .hermes/workflows.sqlite \ - --id wf_reviewable_draft_quickstart + --id wf_typed_quickstart ``` `run` records or replays the workflow instance. It does not pretend the current process is a forever worker. The canonical foreground continuation command is `hermes-workflows runner run --config ...`: it leases queued workflow/step/agent/bash/child-workflow commands, records outputs, and re-enters the workflow until it is waiting for Review Queue input or terminal. The older `hermes-workflows worker --config ...` spelling remains a compatibility alias, but new docs and operators should prefer `runner run` / `runner once`. Respond to the Review Queue request through the Hermes dashboard/plugin or another configured review adapter, then start the runner again if you used the bounded smoke command above. A recorded operator response always creates a visible durable continuation command; the runner, not a hidden chat callback, consumes that command. In a real supervised setup, keep `runner run` alive under launchd, systemd, s6, tmux, or another supervisor only after the foreground command works in your workspace. The response payload must match the `returns=` dataclass schema and include provenance from the adapter that recorded it. +With workflow id `wf_typed_quickstart`, the exact serialized input and state transitions are: + +```json +{"change":"Expose typed workflow contracts."} +``` + +`run` records the initial state: + +```json +{"error":null,"result":null,"status":"running","waiting_on":null,"workflow_id":"wf_typed_quickstart"} +``` + +After the runner executes the credential-free mock agent step, the typed `ask(...)` request is waiting: + +```json +{"error":null,"result":null,"status":"waiting","waiting_on":"signal:operator.response:review_release_note","workflow_id":"wf_typed_quickstart"} +``` + +After an `approve` response with feedback `Ready to ship.` is recorded and the runner continues, the exact result is: + +```json +{"error":null,"result":{"decision":{"action":"approve","feedback":"Ready to ship."},"draft":{"text":"Release note: Expose typed workflow contracts."},"side_effects":{"published":false}},"status":"completed","waiting_on":null,"workflow_id":"wf_typed_quickstart"} +``` + A real always-on setup runs the runner under launchd, systemd, s6, tmux, or another supervisor without `--idle-exit-after`. ## Minimal authoring example @@ -74,45 +98,70 @@ A real always-on setup runs the runner under launchd, systemd, s6, tmux, or anot Workflow code is ordinary Python. `agent(...)` asks a configured runner for typed work. `bash(...)` runs deterministic shell commands as durable runner steps with captured stdout/stderr, exit status, timing, timeouts, and optional redaction. `ask(...)` creates a typed Review Queue request for a human or external reviewer. `parallel(...)`, `pipeline(...)`, and `goal(...)` compose those calls without exposing runtime bookkeeping in the workflow body. ```python +from __future__ import annotations + from dataclasses import dataclass -from typing import Literal +from typing import Literal, Optional from hermes_workflows import agent, ask, workflow -@dataclass +@dataclass(frozen=True) +class ReleaseNoteInput: + change: str + + +@dataclass(frozen=True) class Draft: text: str -@dataclass +@dataclass(frozen=True) class ReviewDecision: action: Literal["approve", "request_changes"] - feedback: str | None = None + feedback: Optional[str] = None + + +@dataclass(frozen=True) +class SideEffects: + published: bool = False + + +@dataclass(frozen=True) +class ReleaseNoteResult: + draft: Draft + decision: ReviewDecision + side_effects: SideEffects @workflow -async def release_note_workflow(inputs): +async def release_note_workflow(inputs: ReleaseNoteInput) -> ReleaseNoteResult: draft = await agent( "writer", prompt="Draft a release note for the supplied change.", - input={"change": inputs["change"]}, + input=inputs, returns=Draft, + # The canonical quickstart must reach typed review without credentials. + mock_output={"text": f"Release note: {inputs.change}"}, ) decision = await ask( - prompt="Review this release note.", + "Review this release note.", key="review_release_note", input=draft, returns=ReviewDecision, ) - return {"draft": draft.text, "decision": decision.action, "side_effects": {"published": False}} + return ReleaseNoteResult( + draft=draft, + decision=decision, + side_effects=SideEffects(), + ) if __name__ == "__main__": - raise SystemExit(release_note_workflow.run()) + raise SystemExit(release_note_workflow.run()) # type: ignore[attr-defined] ``` -The Review Queue schema comes from the `returns=` type. A dataclass with `action: Literal[...]` produces explicit action buttons instead of a raw JSON box. +The first copyable workflow is typed end to end: serialized input is coerced to `ReleaseNoteInput`, `agent(...)` returns `Draft`, `ask(...)` returns `ReviewDecision`, and Python receives `ReleaseNoteResult` while durable status remains JSON. The Review Queue schema comes from the `returns=` type, so `action: Literal[...]` produces explicit action buttons instead of a raw JSON box. Loose dictionaries remain an advanced compatibility input, not the advertised authoring standard. ## Runtime model in one screen diff --git a/docs/authoring.md b/docs/authoring.md index b412057..bfa1898 100644 --- a/docs/authoring.md +++ b/docs/authoring.md @@ -13,35 +13,75 @@ from hermes_workflows import agent, ask, bash, goal, parallel, pipeline, workflo Use those first. `WorkflowEngine`, `@step`, direct `ctx.*` calls, raw signals, approval DTOs, and outbox internals are **not intended for direct use in normal workflows**. They are low-level integration/runtime surfaces for maintainers building adapters or the runtime itself. -## Workflow shape +## Canonical typed quickstart -A normal workflow is an ordinary async Python function with a typed input and a typed result: +A normal workflow is an ordinary async Python function with a typed input and a typed result. This is the canonical copyable quickstart; its serialized input is coerced to `ReleaseNoteInput`, both durable authoring calls return declared types, and the Python result is a `ReleaseNoteResult`: ```python +from __future__ import annotations + from dataclasses import dataclass -from hermes_workflows import workflow +from typing import Literal, Optional +from hermes_workflows import agent, ask, workflow -@dataclass -class EchoInput: - message: str +@dataclass(frozen=True) +class ReleaseNoteInput: + change: str -@dataclass -class EchoResult: - received: str + +@dataclass(frozen=True) +class Draft: + text: str + + +@dataclass(frozen=True) +class ReviewDecision: + action: Literal["approve", "request_changes"] + feedback: Optional[str] = None + + +@dataclass(frozen=True) +class SideEffects: + published: bool = False + + +@dataclass(frozen=True) +class ReleaseNoteResult: + draft: Draft + decision: ReviewDecision + side_effects: SideEffects @workflow -async def my_workflow(inputs: EchoInput) -> EchoResult: - return EchoResult(received=inputs.message) +async def release_note_workflow(inputs: ReleaseNoteInput) -> ReleaseNoteResult: + draft = await agent( + "writer", + prompt="Draft a release note for the supplied change.", + input=inputs, + returns=Draft, + # The canonical quickstart must reach typed review without credentials. + mock_output={"text": f"Release note: {inputs.change}"}, + ) + decision = await ask( + "Review this release note.", + key="review_release_note", + input=draft, + returns=ReviewDecision, + ) + return ReleaseNoteResult( + draft=draft, + decision=decision, + side_effects=SideEffects(), + ) if __name__ == "__main__": - raise SystemExit(my_workflow.run()) + raise SystemExit(release_note_workflow.run()) # type: ignore[attr-defined] ``` -The runtime records completed work durably. On replay, completed steps return stored outputs and only missing work is queued or executed. +Run it with `--input-json '{"change":"Expose typed workflow contracts."}'`. The first durable run is `running`, the runner reaches `signal:operator.response:review_release_note`, and an approved typed response produces the nested `ReleaseNoteResult` JSON shown in the [setup guide](setup-for-agents.html). The runtime records completed work durably; on replay, completed steps return stored outputs and only missing work is queued or executed. Loose dictionary contracts remain available for advanced compatibility, but they are secondary and should not be the first workflow an author copies. ## `agent(...)`: typed AI or worker work diff --git a/docs/index.md b/docs/index.md index c404fb0..a8ed10b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -43,6 +43,34 @@ Use `agent(...)` for typed AI/worker work, `ask(...)` for typed human or externa +## Canonical typed quickstart + +Start with the fully typed `ReleaseNoteInput` → `Draft` → `ReviewDecision` → `ReleaseNoteResult` example in [Author workflows](authoring.html). The same executable source ships as `hermes_workflows.examples.install_smoke:release_note_workflow`; loose dictionary contracts are compatibility-only, not the primary authoring standard. + +Run it with this exact serialized input: + +```json +{"change":"Expose typed workflow contracts."} +``` + +The initial run records: + +```json +{"error":null,"result":null,"status":"running","waiting_on":null,"workflow_id":"wf_typed_quickstart"} +``` + +After the foreground runner executes the credential-free mock agent call, the typed Review Queue request is visible: + +```json +{"error":null,"result":null,"status":"waiting","waiting_on":"signal:operator.response:review_release_note","workflow_id":"wf_typed_quickstart"} +``` + +After an `approve` response with feedback `Ready to ship.` and runner continuation, the typed Python result is serialized exactly as: + +```json +{"error":null,"result":{"decision":{"action":"approve","feedback":"Ready to ship."},"draft":{"text":"Release note: Expose typed workflow contracts."},"side_effects":{"published":false}},"status":"completed","waiting_on":null,"workflow_id":"wf_typed_quickstart"} +``` + ## Dashboard preview
diff --git a/docs/setup-for-agents.md b/docs/setup-for-agents.md index 1f7d6fc..3c3d8af 100644 --- a/docs/setup-for-agents.md +++ b/docs/setup-for-agents.md @@ -21,7 +21,7 @@ python -m pip install . hermes-workflows --help hermes-workflows doctor \ --db .hermes/workflows.sqlite \ - --workflow-ref hermes_workflows.examples.reviewable_draft:reviewable_draft_workflow + --workflow-ref hermes_workflows.examples.install_smoke:release_note_workflow ``` Until a package-index release is published, install from a trusted source checkout. Do not use `pip install hermes-workflows`, `uvx`, or `pipx` launch instructions yet. @@ -45,10 +45,9 @@ Put workflow aliases and DB aliases in `.hermes/workflows.registry.json`: "default": "workflows.sqlite" }, "workflows": { - "reviewable-draft": { - "workflow_ref": "hermes_workflows.examples.reviewable_draft:reviewable_draft_workflow", - "db": "default", - "default_input": {} + "typed-quickstart": { + "workflow_ref": "hermes_workflows.examples.install_smoke:release_note_workflow", + "db": "default" } } } @@ -67,14 +66,24 @@ hermes-workflows registry doctor --config .hermes/workflows.registry.json Start or replay a workflow instance through the registry: ```bash -hermes-workflows run reviewable-draft \ +hermes-workflows run typed-quickstart \ --config .hermes/workflows.registry.json \ - --id wf_reviewable_draft_demo \ - --input-json '{"topic":"Hermes Workflows launch"}' + --id wf_typed_quickstart \ + --input-json '{"change":"Expose typed workflow contracts."}' ``` `run` records the workflow activation and queues missing work. It is not the always-on continuation loop. A run can return `running` before the Review Queue request exists because a runner still needs to execute queued steps and replay the workflow to the next wait. +The exact serialized input and initial `run` result are: + +```json +{"change":"Expose typed workflow contracts."} +``` + +```json +{"error":null,"result":null,"status":"running","waiting_on":null,"workflow_id":"wf_typed_quickstart"} +``` + ## 4. Run the foreground Workflow Runner v2 For recurring agent-owned workflows, first run the canonical runner in the foreground from the same registry. Defer daemon/supervisor setup until this command can drain work in the operator workspace: @@ -99,6 +108,12 @@ hermes-workflows runner run \ The runner leases runnable or lease-expired `run_workflow`, `run_step`, `external_agent`, and child-workflow commands from configured DBs. It loads each instance's stored `workflow_ref` through the registry, executes the command, records durable output, and replays the workflow until it reaches a Review Queue request, another durable wait, or a terminal state. +For this credential-free quickstart, the runner executes the deterministic mock agent result and reaches the typed review wait exactly: + +```json +{"error":null,"result":null,"status":"waiting","waiting_on":"signal:operator.response:review_release_note","workflow_id":"wf_typed_quickstart"} +``` + `agent(...)` already runs through the existing agent-step machinery: the workflow emits an `external_agent` command, the runner calls `WorkflowEngine.agent_runner`, and the canonical `hermes_workflows.agent_runner.SubprocessAgentRunner` runs the configured adapter command. The compatibility module `hermes_workflows.runners` re-exports the same runner classes for older code. For Hermes CLI, keep using that path: `agent_cli_adapter` receives the durable runner request on stdin, expands `agent(..., model="...")` with `--agent-model-arg`, and passes the rendered prompt to Hermes as `--oneshot ` with `--agent-prompt-arg`. ```text @@ -161,40 +176,70 @@ export HERMES_WORKFLOWS_AGENT_MODEL_ARGS_JSON='["--model={model}"]' Launch-facing workflow authors should import the small facade: ```python +from __future__ import annotations + from dataclasses import dataclass -from typing import Literal +from typing import Literal, Optional + +from hermes_workflows import agent, ask, workflow -from hermes_workflows import agent, ask, bash, goal, parallel, pipeline, workflow +@dataclass(frozen=True) +class ReleaseNoteInput: + change: str -@dataclass + +@dataclass(frozen=True) +class Draft: + text: str + + +@dataclass(frozen=True) class ReviewDecision: action: Literal["approve", "request_changes"] - feedback: str | None = None + feedback: Optional[str] = None + + +@dataclass(frozen=True) +class SideEffects: + published: bool = False + + +@dataclass(frozen=True) +class ReleaseNoteResult: + draft: Draft + decision: ReviewDecision + side_effects: SideEffects @workflow -async def reviewable_draft_workflow(inputs): +async def release_note_workflow(inputs: ReleaseNoteInput) -> ReleaseNoteResult: draft = await agent( "writer", - prompt="Draft a concise packet for the requested topic.", - input={"topic": inputs["topic"]}, - returns=dict, + prompt="Draft a release note for the supplied change.", + input=inputs, + returns=Draft, + # The canonical quickstart must reach typed review without credentials. + mock_output={"text": f"Release note: {inputs.change}"}, ) decision = await ask( - prompt="Review this packet.", - key="review_packet", + "Review this release note.", + key="review_release_note", input=draft, returns=ReviewDecision, ) - return {"draft": draft, "decision": decision.action, "side_effects": {"sent": 0}} + return ReleaseNoteResult( + draft=draft, + decision=decision, + side_effects=SideEffects(), + ) if __name__ == "__main__": - raise SystemExit(reviewable_draft_workflow.run()) + raise SystemExit(release_note_workflow.run()) # type: ignore[attr-defined] ``` -Use `parallel([...])` for fan-out/fan-in, `pipeline(items, stage_a, stage_b, ...)` for staged item work, `bash(...)` for deterministic shell checks, and `goal(do_fn, check_fn, max_iters=...)` for bounded improve-until-accepted loops. Avoid teaching new users `WorkflowEngine`, low-level runtime context APIs, `step`, or manual command draining unless you are writing an adapter, migration, or advanced test. See [Author workflows](authoring.html) for the complete launch-facing SDK guide. +The canonical workflow boundary is typed end to end. Loose dictionary inputs are a secondary compatibility surface, not the standard to advertise. Use `parallel([...])` for fan-out/fan-in, `pipeline(items, stage_a, stage_b, ...)` for staged item work, `bash(...)` for deterministic shell checks, and `goal(do_fn, check_fn, max_iters=...)` for bounded improve-until-accepted loops. Avoid teaching new users `WorkflowEngine`, low-level runtime context APIs, `step`, or manual command draining unless you are writing an adapter, migration, or advanced test. See [Author workflows](authoring.html) for the complete launch-facing SDK guide. ## 6. Record human decisions @@ -205,9 +250,9 @@ Hermes plugin/tool shape: ```json { "db": "default", - "workflow_id": "wf_reviewable_draft_demo", - "key": "review_draft_packet", - "payload": {"action": "approve", "feedback": null}, + "workflow_id": "wf_typed_quickstart", + "key": "review_release_note", + "payload": {"action": "approve", "feedback": "Ready to ship."}, "by": "operator", "channel": "dashboard", "resume": false @@ -216,6 +261,12 @@ Hermes plugin/tool shape: Review Queue responses create an inspectable workflow continuation. With `resume=false`, the runtime only records the operator response and leaves a visible `run_workflow` continuation command with reason `operator_response`; a trusted foreground runner must consume that command. Trusted local adapters may still request `resume=true`, but operators should treat the returned post-resume state and command history as the source of truth. Continuation should be observable in `hermes-workflows status --commands recent` / `hermes-workflows runner status`, not hidden inside a chat callback. +After recording the `approve` response above with feedback `Ready to ship.` and running the continuation, the exact terminal result is: + +```json +{"error":null,"result":{"decision":{"action":"approve","feedback":"Ready to ship."},"draft":{"text":"Release note: Expose typed workflow contracts."},"side_effects":{"published":false}},"status":"completed","waiting_on":null,"workflow_id":"wf_typed_quickstart"} +``` + ## 7. Configure the Hermes dashboard/plugin The Hermes plugin should point at the same DB aliases and workflow catalog that the CLI and runner use. A mismatched dashboard DB is the fastest way to make real approvals look missing. diff --git a/src/hermes_workflows/plugin_skills/hermes-workflows-creating/SKILL.md b/src/hermes_workflows/plugin_skills/hermes-workflows-creating/SKILL.md index ed2e18d..76e3fee 100644 --- a/src/hermes_workflows/plugin_skills/hermes-workflows-creating/SKILL.md +++ b/src/hermes_workflows/plugin_skills/hermes-workflows-creating/SKILL.md @@ -36,63 +36,76 @@ Do not expose runtime plumbing (`ctx.handoff`, raw signals, internal waits, leas 6. Record receipts: commands run, stdout/stderr/exit code, artifact paths, external handles, side-effect ledger. 7. Add smoke tests that run without provider credentials by using `mock_output` where appropriate. -## Minimal skeleton +## Canonical typed skeleton + +This is the primary copyable workflow. Keep the workflow input, agent output, review response, and workflow result typed end to end; do not normalize a loose dictionary inside the workflow body. ```python from __future__ import annotations -from dataclasses import dataclass, field -from typing import Literal +from dataclasses import dataclass +from typing import Literal, Optional -from hermes_workflows import agent, ask, bash, parallel, workflow +from hermes_workflows import agent, ask, workflow -@dataclass -class MyWorkflowInput: - topic: str +@dataclass(frozen=True) +class ReleaseNoteInput: + change: str -@dataclass -class DraftPacket: - title: str - body: str - risks: list[str] = field(default_factory=list) +@dataclass(frozen=True) +class Draft: + text: str -@dataclass +@dataclass(frozen=True) class ReviewDecision: - action: Literal["approve", "request_changes", "drop"] - feedback: str | None = None + action: Literal["approve", "request_changes"] + feedback: Optional[str] = None -@workflow -async def my_workflow(inputs: MyWorkflowInput) -> dict: - req = inputs if isinstance(inputs, MyWorkflowInput) else MyWorkflowInput(**dict(inputs or {})) +@dataclass(frozen=True) +class SideEffects: + published: bool = False + + +@dataclass(frozen=True) +class ReleaseNoteResult: + draft: Draft + decision: ReviewDecision + side_effects: SideEffects - preflight = await bash( - "python -V && git status --short", - key="preflight", - timeout_seconds=60, - ) +@workflow +async def release_note_workflow(inputs: ReleaseNoteInput) -> ReleaseNoteResult: draft = await agent( - "draft_packet", - prompt="Create a concise reviewable draft from the input.", - input={"request": req, "preflight": preflight.stdout[-4000:]}, - returns=DraftPacket, - mock_output={"title": req.topic, "body": "Demo draft", "risks": []}, + "writer", + prompt="Draft a release note for the supplied change.", + input=inputs, + returns=Draft, + # The canonical quickstart must reach typed review without credentials. + mock_output={"text": f"Release note: {inputs.change}"}, ) - decision = await ask( - "Review this draft before any external side effect.", - key="review_draft_packet", - input={"draft": draft, "side_effects": {"published": False}}, + "Review this release note.", + key="review_release_note", + input=draft, returns=ReviewDecision, ) + return ReleaseNoteResult( + draft=draft, + decision=decision, + side_effects=SideEffects(), + ) - return {"status": decision.action, "draft": draft, "decision": decision} + +if __name__ == "__main__": + raise SystemExit(release_note_workflow.run()) # type: ignore[attr-defined] ``` +Serialized dictionary input is coerced at the framework boundary. A loose `dict` workflow signature is compatibility-only and must not replace this typed standard in generated or documented workflows. + ## Step design rules - Prefer small named steps with stable keys; keys should describe the public review/action, not internal plumbing. diff --git a/tests/test_launch_examples.py b/tests/test_launch_examples.py index 964392d..e911d76 100644 --- a/tests/test_launch_examples.py +++ b/tests/test_launch_examples.py @@ -9,6 +9,13 @@ REPO_ROOT = Path(__file__).resolve().parents[1] +CANONICAL_TYPED_QUICKSTART = REPO_ROOT / "docs" / "snippets" / "typed_quickstart.py" +PRIMARY_COPYABLE_DOCS = ( + REPO_ROOT / "README.md", + REPO_ROOT / "docs" / "authoring.md", + REPO_ROOT / "docs" / "setup-for-agents.md", + REPO_ROOT / "src" / "hermes_workflows" / "plugin_skills" / "hermes-workflows-creating" / "SKILL.md", +) def _load_example(filename: str, attr: str): @@ -21,6 +28,29 @@ def _load_example(filename: str, attr: str): return getattr(module, attr) +def _load_example_from_path(path: Path, module_name: str): + spec = importlib.util.spec_from_file_location(module_name, path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _canonical_typed_quickstart_body() -> str: + source = CANONICAL_TYPED_QUICKSTART.read_text() + marker = "from __future__ import annotations" + return marker + source.split(marker, 1)[1] + + +def _extract_canonical_typed_quickstart(path: Path) -> str: + for fenced in path.read_text().split("```python")[1:]: + source, separator, _ = fenced.partition("```") + if separator and "class ReleaseNoteInput:" in source and "class ReleaseNoteResult:" in source: + return source.strip() + "\n" + raise AssertionError(f"{path.relative_to(REPO_ROOT)} does not publish the canonical typed quickstart") + + def _drain(engine: WorkflowEngine, workflow_id: str, *, max_commands: int = 20): result = None for _ in range(max_commands): @@ -59,6 +89,44 @@ def test_installed_reviewable_draft_quickstart_reaches_typed_review_queue(tmp_pa assert _review_keys(engine, "wf_reviewable_draft") == {"review_draft_packet"} +def test_primary_copyable_docs_execute_the_canonical_typed_wait_and_result(tmp_path): + expected_source = _canonical_typed_quickstart_body() + + for index, path in enumerate(PRIMARY_COPYABLE_DOCS): + extracted = _extract_canonical_typed_quickstart(path) + assert extracted == expected_source + + snippet = tmp_path / f"typed-quickstart-{index}.py" + snippet.write_text(extracted) + module = _load_example_from_path(snippet, f"published_typed_quickstart_{index}") + workflow_id = f"wf_published_typed_quickstart_{index}" + engine = WorkflowEngine(tmp_path / f"published-{index}.sqlite") + + waiting = engine.run_until_idle( + module.release_note_workflow, + {"change": "Expose typed workflow contracts."}, + workflow_id=workflow_id, + ) + + assert waiting.status == "waiting" + assert waiting.waiting_on == "signal:operator.response:review_release_note" + engine.submit_operator_response( + workflow_id=workflow_id, + key="review_release_note", + payload={"action": "approve", "feedback": "Ready to ship."}, + source={"kind": "human", "id": "reviewer", "channel": "test", "message_id": f"docs-{index}"}, + ) + completed = engine.drain(workflow_id) + + assert completed.status == "completed" + assert isinstance(completed.result, module.ReleaseNoteResult) + assert completed.result == module.ReleaseNoteResult( + draft=module.Draft(text="Release note: Expose typed workflow contracts."), + decision=module.ReviewDecision(action="approve", feedback="Ready to ship."), + side_effects=module.SideEffects(published=False), + ) + + def test_launch_examples_reach_expected_review_queue_requests(tmp_path): cases = [ ("typed_review.py", "typed_review_workflow", "wf_typed_review", {"review_draft_brief"}), diff --git a/tests/test_public_facade.py b/tests/test_public_facade.py index cf57486..ad1c2f0 100644 --- a/tests/test_public_facade.py +++ b/tests/test_public_facade.py @@ -1,8 +1,26 @@ from __future__ import annotations +from pathlib import Path + import hermes_workflows +REPO_ROOT = Path(__file__).resolve().parents[1] +PRIMARY_BEHAVIOR_DOCS = ( + REPO_ROOT / "README.md", + REPO_ROOT / "docs" / "index.md", + REPO_ROOT / "docs" / "setup-for-agents.md", +) +STARTED_JSON = '{"error":null,"result":null,"status":"running","waiting_on":null,"workflow_id":"wf_typed_quickstart"}' +WAITING_JSON = '{"error":null,"result":null,"status":"waiting","waiting_on":"signal:operator.response:review_release_note","workflow_id":"wf_typed_quickstart"}' +RESULT_JSON = ( + '{"error":null,"result":{"decision":{"action":"approve","feedback":"Ready to ship."},' + '"draft":{"text":"Release note: Expose typed workflow contracts."},' + '"side_effects":{"published":false}},"status":"completed","waiting_on":null,' + '"workflow_id":"wf_typed_quickstart"}' +) + + def test_top_level_public_facade_teaches_authoring_primitives_and_artifacts_only() -> None: assert set(hermes_workflows.__all__) == { "Artifact", @@ -49,6 +67,15 @@ def test_top_level_public_facade_teaches_authoring_primitives_and_artifacts_only assert "step" not in hermes_workflows.__all__ +def test_primary_docs_publish_exact_typed_quickstart_behavior() -> None: + for path in PRIMARY_BEHAVIOR_DOCS: + docs = path.read_text() + assert '{"change":"Expose typed workflow contracts."}' in docs + assert STARTED_JSON in docs + assert WAITING_JSON in docs + assert RESULT_JSON in docs + + def test_top_level_dir_hides_advanced_compatibility_shims() -> None: visible = set(dir(hermes_workflows))