rote-cli 0.1.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (27) hide show
  1. {rote_cli-0.1.0 → rote_cli-0.3.0}/PKG-INFO +37 -3
  2. {rote_cli-0.1.0 → rote_cli-0.3.0}/README.md +36 -2
  3. {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/__init__.py +1 -1
  4. {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/adapters/_common.py +4 -1
  5. {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/adapters/cloudflare.py +181 -1
  6. {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/adapters/dbos.py +26 -6
  7. {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/cli.py +9 -1
  8. {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/serve/backends.py +34 -9
  9. {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/serve/registry.py +10 -6
  10. {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/serve/server.py +28 -3
  11. {rote_cli-0.1.0 → rote_cli-0.3.0}/.gitignore +0 -0
  12. {rote_cli-0.1.0 → rote_cli-0.3.0}/LICENSE +0 -0
  13. {rote_cli-0.1.0 → rote_cli-0.3.0}/pyproject.toml +0 -0
  14. {rote_cli-0.1.0 → rote_cli-0.3.0}/skills/rote-graduate/SKILL.md +0 -0
  15. {rote_cli-0.1.0 → rote_cli-0.3.0}/skills/rote-graduate/references/crystallization-heuristics.md +0 -0
  16. {rote_cli-0.1.0 → rote_cli-0.3.0}/skills/rote-graduate/references/ir-schema.md +0 -0
  17. {rote_cli-0.1.0 → rote_cli-0.3.0}/skills/rote-graduate/references/llm-judge-extraction.md +0 -0
  18. {rote_cli-0.1.0 → rote_cli-0.3.0}/skills/rote-graduate/references/node-kinds.md +0 -0
  19. {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/adapters/__init__.py +0 -0
  20. {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/adapters/temporal.py +0 -0
  21. {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/graduator/__init__.py +0 -0
  22. {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/graduator/drivers/__init__.py +0 -0
  23. {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/graduator/drivers/anthropic_api.py +0 -0
  24. {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/graduator/drivers/claude.py +0 -0
  25. {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/graduator/drivers/codex.py +0 -0
  26. {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/ir.py +0 -0
  27. {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/serve/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rote-cli
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Graduate fuzzy AI skills into deterministic, reliable workflows
5
5
  Project-URL: Homepage, https://github.com/trevhud/rote
6
6
  Project-URL: Repository, https://github.com/trevhud/rote
@@ -242,9 +242,43 @@ agent-driven — so the same IR always produces byte-identical output.
242
242
 
243
243
  ## Quickstart
244
244
 
245
- ### Install
245
+ ### Use from Claude Code (recommended)
246
246
 
247
- `rote` is not on PyPI yet. Clone and install in editable mode:
247
+ `rote` ships as a Claude Code plugin, so you can graduate a skill
248
+ without leaving Claude or touching Python tooling:
249
+
250
+ ```
251
+ /plugin marketplace add trevhud/rote
252
+ /plugin install rote@rote
253
+ ```
254
+
255
+ Then say "graduate this skill" (or run `/rote:graduate` directly).
256
+ The plugin confirms the source skill directory, asks which runtime you
257
+ want (Temporal, Cloudflare Workflows, or DBOS), runs the CLI via
258
+ [uv](https://docs.astral.sh/uv/) in the background, and reports the
259
+ emitted pipeline. A second skill, `/rote:serve`, wires graduated
260
+ pipelines up as MCP tools so Claude can trigger the deployed workflows.
261
+
262
+ Prefer a terminal? The same thing is one `uvx` command:
263
+
264
+ ```sh
265
+ uvx --from rote-cli rote graduate ./my-skill --runtime dbos --out ./graduated
266
+
267
+ # or straight from GitHub for unreleased changes:
268
+ uvx --from git+https://github.com/trevhud/rote rote graduate \
269
+ ./my-skill --runtime dbos --out ./graduated
270
+ ```
271
+
272
+ > **Naming note:** the `rote` package on PyPI is an unrelated
273
+ > memoization library that also installs `import rote`, so the two
274
+ > can't share an environment. This project's distribution is
275
+ > `rote-cli` while the CLI command and import name stay `rote` —
276
+ > hence `uvx --from rote-cli rote ...`. See
277
+ > [docs/releasing.md](docs/releasing.md).
278
+
279
+ ### Install from source (development)
280
+
281
+ Clone and install in editable mode:
248
282
 
249
283
  ```sh
250
284
  git clone https://github.com/trevhud/rote.git
@@ -196,9 +196,43 @@ agent-driven — so the same IR always produces byte-identical output.
196
196
 
197
197
  ## Quickstart
198
198
 
199
- ### Install
199
+ ### Use from Claude Code (recommended)
200
200
 
201
- `rote` is not on PyPI yet. Clone and install in editable mode:
201
+ `rote` ships as a Claude Code plugin, so you can graduate a skill
202
+ without leaving Claude or touching Python tooling:
203
+
204
+ ```
205
+ /plugin marketplace add trevhud/rote
206
+ /plugin install rote@rote
207
+ ```
208
+
209
+ Then say "graduate this skill" (or run `/rote:graduate` directly).
210
+ The plugin confirms the source skill directory, asks which runtime you
211
+ want (Temporal, Cloudflare Workflows, or DBOS), runs the CLI via
212
+ [uv](https://docs.astral.sh/uv/) in the background, and reports the
213
+ emitted pipeline. A second skill, `/rote:serve`, wires graduated
214
+ pipelines up as MCP tools so Claude can trigger the deployed workflows.
215
+
216
+ Prefer a terminal? The same thing is one `uvx` command:
217
+
218
+ ```sh
219
+ uvx --from rote-cli rote graduate ./my-skill --runtime dbos --out ./graduated
220
+
221
+ # or straight from GitHub for unreleased changes:
222
+ uvx --from git+https://github.com/trevhud/rote rote graduate \
223
+ ./my-skill --runtime dbos --out ./graduated
224
+ ```
225
+
226
+ > **Naming note:** the `rote` package on PyPI is an unrelated
227
+ > memoization library that also installs `import rote`, so the two
228
+ > can't share an environment. This project's distribution is
229
+ > `rote-cli` while the CLI command and import name stay `rote` —
230
+ > hence `uvx --from rote-cli rote ...`. See
231
+ > [docs/releasing.md](docs/releasing.md).
232
+
233
+ ### Install from source (development)
234
+
235
+ Clone and install in editable mode:
202
236
 
203
237
  ```sh
204
238
  git clone https://github.com/trevhud/rote.git
@@ -1,3 +1,3 @@
1
1
  """rote — graduate fuzzy AI skills into deterministic workflows."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.3.0"
@@ -42,7 +42,10 @@ def _pipeline_hash(pipeline: Pipeline) -> str:
42
42
  name) so a regenerated pipeline becomes a new type. Old in-flight
43
43
  workflows continue on the old code; new workflows use the new code.
44
44
  """
45
- payload = f"{pipeline.name}|{pipeline.version}|{len(pipeline.nodes)}|{len(pipeline.edges)}"
45
+ # Hash the full validated contents: node wiring, inputs, retries, and
46
+ # edges all participate, so any regeneration that changes behavior gets
47
+ # a new workflow type — name/version/counts alone miss rewires.
48
+ payload = pipeline.model_dump_json(by_alias=True, exclude_none=True)
46
49
  return hashlib.sha256(payload.encode()).hexdigest()[:8]
47
50
 
48
51
 
@@ -551,7 +551,15 @@ function interpolate(template: string, vars: Record<string, unknown>): string {
551
551
  : undefined,
552
552
  vars,
553
553
  );
554
- if (value === undefined) return "";
554
+ if (value === undefined) {
555
+ // A hole in a judge prompt produces confident garbage that is
556
+ // far harder to debug than an error naming the missing field.
557
+ throw new Error(
558
+ `prompt template references {{ ${key} }} but the input has no ` +
559
+ `such field; available top-level keys: ${Object.keys(vars).sort().join(", ")}`,
560
+ );
561
+ }
562
+ if (value === null) return "";
555
563
  return typeof value === "string" ? value : JSON.stringify(value);
556
564
  });
557
565
  }
@@ -902,6 +910,168 @@ def emit_tsconfig() -> str:
902
910
  return json.dumps(obj, indent=2) + "\n"
903
911
 
904
912
 
913
+ # ───────── README.md + .dev.vars.example emission ─────────
914
+
915
+ # "Deploy to Cloudflare" button spec, per
916
+ # https://developers.cloudflare.com/workers/platform/deploy-buttons/:
917
+ # a markdown image link whose target is the deploy service with the
918
+ # public GitHub/GitLab repo URL passed via the ``url`` query parameter.
919
+ # The repo URL is unknowable at emission time, so the emitted README
920
+ # carries an explicit placeholder for the user to substitute.
921
+ _DEPLOY_BUTTON_IMAGE = "https://deploy.workers.cloudflare.com/button"
922
+ _DEPLOY_BUTTON_BASE = "https://deploy.workers.cloudflare.com/?url="
923
+ _REPO_URL_PLACEHOLDER = "REPLACE-WITH-YOUR-REPO-URL"
924
+
925
+
926
+ def _llm_clients(pipeline: Pipeline) -> set[str]:
927
+ """Vendor clients used by the pipeline's llm_judge signature specs."""
928
+ return {
929
+ node.signature_spec.client
930
+ for node in pipeline.nodes
931
+ if node.kind is NodeKind.LLM_JUDGE and node.signature_spec is not None
932
+ }
933
+
934
+
935
+ def _secret_names(pipeline: Pipeline) -> list[str]:
936
+ """Secrets the emitted worker reads, in emission order.
937
+
938
+ ``ANTHROPIC_API_KEY`` is always present (the emitted ``Env``
939
+ interface requires it); ``OPENAI_API_KEY`` joins when any signature
940
+ targets the OpenAI client.
941
+ """
942
+ secrets = ["ANTHROPIC_API_KEY"]
943
+ if "openai" in _llm_clients(pipeline):
944
+ secrets.append("OPENAI_API_KEY")
945
+ return secrets
946
+
947
+
948
+ def emit_dev_vars_example(pipeline: Pipeline) -> str:
949
+ """Emit ``.dev.vars.example`` — dotenv-format secret declarations.
950
+
951
+ The Deploy to Cloudflare flow reads this file to know which secrets
952
+ to prompt for during one-click setup (per the deploy-buttons docs);
953
+ ``wrangler dev`` users copy it to ``.dev.vars`` for local runs.
954
+ """
955
+ lines = [
956
+ "# Copy to .dev.vars for local `wrangler dev`; the Deploy to Cloudflare",
957
+ "# flow reads this file to prompt for secrets during one-click setup.",
958
+ "# For a manual deploy, set each with `npx wrangler secret put <NAME>`.",
959
+ ]
960
+ lines.extend(f"{name}=" for name in _secret_names(pipeline))
961
+ return "\n".join(lines) + "\n"
962
+
963
+
964
+ def emit_readme(pipeline: Pipeline, cfg: CloudflareAdapterConfig) -> str:
965
+ deploy_url = f"{_DEPLOY_BUTTON_BASE}{_REPO_URL_PLACEHOLDER}"
966
+ button = f"[![Deploy to Cloudflare]({_DEPLOY_BUTTON_IMAGE})]({deploy_url})"
967
+
968
+ description = pipeline.description.strip()
969
+ description_block = f"\n{description}\n" if description else ""
970
+
971
+ secrets = _secret_names(pipeline)
972
+ secret_puts = "\n".join(f"npx wrangler secret put {name}" for name in secrets)
973
+
974
+ gates = [n for n in pipeline.nodes if n.kind is NodeKind.HITL_GATE]
975
+ first_signal = gates[0].signal if gates and gates[0].signal else "example_signal"
976
+ gate_lines = "\n".join(
977
+ f"| `{g.id}` | `{g.signal}` | {_ir_duration_to_cf(g.timeout or cfg.default_hitl_timeout)} |"
978
+ for g in gates
979
+ )
980
+
981
+ # Built flush-left (not textwrap.dedent) because interpolated
982
+ # multi-line values — the pipeline description, gate table rows —
983
+ # contain unindented lines that would defeat dedent's common-prefix
984
+ # detection.
985
+ return f"""\
986
+ # {pipeline.name} — Cloudflare Workflows runtime
987
+
988
+ Auto-generated by `rote emit --runtime cloudflare`. Do not edit
989
+ generated files by hand; re-run the emitter to regenerate.
990
+ {description_block}
991
+ ## Deploy to Cloudflare
992
+
993
+ {button}
994
+
995
+ Push this directory to a **public GitHub or GitLab repository**,
996
+ then replace `{_REPO_URL_PLACEHOLDER}` in the button link above with
997
+ the repo URL (a subdirectory path works too). One click clones the
998
+ repo into the visitor's account, provisions the workflow, and
999
+ prompts for the secrets declared in `.dev.vars.example`.
1000
+
1001
+ ## Layout
1002
+
1003
+ - `src/workflow.ts` — the `WorkflowEntrypoint` class: one `step.do`
1004
+ per node, `step.waitForEvent` per HITL gate
1005
+ - `src/index.ts` — fetch handler that creates a workflow instance
1006
+ from the POST body
1007
+ - `src/extracted/` — stubs for deterministic nodes; fill these in
1008
+ with direct vendor API calls (they throw until you do)
1009
+ - `src/signatures/` — typed LLM judges generated from the pipeline
1010
+ IR (Zod schemas + direct vendor SDK calls)
1011
+ - `wrangler.jsonc` / `package.json` / `tsconfig.json` — deploy-ready
1012
+ Workers project config
1013
+
1014
+ ## Deploy from this machine
1015
+
1016
+ ```sh
1017
+ npm install
1018
+ {secret_puts}
1019
+ npx wrangler deploy
1020
+ ```
1021
+
1022
+ ## Trigger a run
1023
+
1024
+ POST the pipeline input to the deployed worker; it responds with
1025
+ the new instance's id and status:
1026
+
1027
+ ```sh
1028
+ curl -X POST https://{pipeline.name}.<your-subdomain>.workers.dev \\
1029
+ -H 'content-type: application/json' \\
1030
+ -d '{{"your": "input"}}'
1031
+ ```
1032
+
1033
+ Or trigger directly through wrangler:
1034
+
1035
+ ```sh
1036
+ npx wrangler workflows trigger {pipeline.name} '{{"your": "input"}}'
1037
+ ```
1038
+
1039
+ ## HITL gates
1040
+
1041
+ The workflow parks durably at each gate until an event of the
1042
+ gate's type arrives:
1043
+
1044
+ | Gate | Event type | Timeout |
1045
+ | --- | --- | --- |
1046
+ {gate_lines}
1047
+
1048
+ Resume a parked instance by sending the event (`latest` targets the
1049
+ most recent instance):
1050
+
1051
+ ```sh
1052
+ npx wrangler workflows instances send-event {pipeline.name} latest \\
1053
+ --type {first_signal} --payload '{{"approved": true}}'
1054
+ ```
1055
+
1056
+ A gate that times out fails the run — silence is not approval.
1057
+
1058
+ ## Trigger from Claude (MCP)
1059
+
1060
+ `rote` can expose this deployed pipeline as an MCP tool so any MCP
1061
+ client triggers the deterministic workflow instead of re-running
1062
+ the fuzzy skill:
1063
+
1064
+ ```sh
1065
+ rote register <graduated-output-dir> \\
1066
+ --runtime cloudflare \\
1067
+ --url https://{pipeline.name}.<your-subdomain>.workers.dev
1068
+ rote serve
1069
+ ```
1070
+
1071
+ See `docs/mcp-trigger.md` in the rote repository for the full flow.
1072
+ """
1073
+
1074
+
905
1075
  # ───────── Adapter facade ─────────
906
1076
 
907
1077
 
@@ -911,6 +1081,8 @@ class CloudflareAdapter:
911
1081
  Output layout::
912
1082
 
913
1083
  out/
1084
+ README.md
1085
+ .dev.vars.example
914
1086
  wrangler.jsonc
915
1087
  package.json
916
1088
  tsconfig.json
@@ -975,4 +1147,12 @@ class CloudflareAdapter:
975
1147
  ts_path.write_text(emit_tsconfig(), encoding="utf-8")
976
1148
  written["tsconfig.json"] = ts_path
977
1149
 
1150
+ readme_path = out / "README.md"
1151
+ readme_path.write_text(emit_readme(pipeline, self.config), encoding="utf-8")
1152
+ written["README"] = readme_path
1153
+
1154
+ dev_vars_path = out / ".dev.vars.example"
1155
+ dev_vars_path.write_text(emit_dev_vars_example(pipeline), encoding="utf-8")
1156
+ written[".dev.vars.example"] = dev_vars_path
1157
+
978
1158
  return written
@@ -523,12 +523,24 @@ def emit_signature_module(node: Node, cfg: DbosAdapterConfig) -> str:
523
523
  interpolate_block = textwrap.dedent(
524
524
  '''\
525
525
  def _interpolate(template: str, variables: dict[str, Any]) -> str:
526
- """Resolve ``{{ dotted.path }}`` placeholders against the input dict."""
526
+ """Resolve ``{{ dotted.path }}`` placeholders against the input dict.
527
+
528
+ An unresolvable placeholder raises instead of interpolating "" —
529
+ a hole in a judge prompt produces confident garbage that is far
530
+ harder to debug than a KeyError naming the missing variable.
531
+ """
527
532
 
528
533
  def _resolve(match: re.Match[str]) -> str:
534
+ path = match.group(1)
529
535
  value: Any = variables
530
- for part in match.group(1).split("."):
531
- value = value.get(part) if isinstance(value, dict) else None
536
+ for part in path.split("."):
537
+ if not isinstance(value, dict) or part not in value:
538
+ raise KeyError(
539
+ f"prompt template references {{{{ {path} }}}} but the "
540
+ f"input has no such field; available top-level keys: "
541
+ f"{sorted(variables)}"
542
+ )
543
+ value = value[part]
532
544
  if value is None:
533
545
  return ""
534
546
  return value if isinstance(value, str) else json.dumps(value, default=str)
@@ -1087,9 +1099,12 @@ def emit_readme(pipeline: Pipeline, cfg: DbosAdapterConfig) -> str:
1087
1099
  for g in gates
1088
1100
  )
1089
1101
  first_signal = gates[0].signal if gates else "example_signal"
1090
- return textwrap.dedent(
1091
- f"""\
1092
- # {pipeline.name} DBOS runtime
1102
+ # Dedent BEFORE interpolating: a multi-line gate_lines value would put
1103
+ # column-0 lines inside the template, making dedent a no-op and shipping
1104
+ # the whole README indented (Markdown renders that as one code block).
1105
+ template = textwrap.dedent(
1106
+ """\
1107
+ # {pipeline_name} — DBOS runtime
1093
1108
 
1094
1109
  Auto-generated by `rote emit --runtime dbos`. Do not edit generated
1095
1110
  files by hand; re-run the emitter to regenerate.
@@ -1150,6 +1165,11 @@ def emit_readme(pipeline: Pipeline, cfg: DbosAdapterConfig) -> str:
1150
1165
  run — silence is not approval.
1151
1166
  """
1152
1167
  )
1168
+ return template.format(
1169
+ pipeline_name=pipeline.name,
1170
+ gate_lines=gate_lines,
1171
+ first_signal=first_signal,
1172
+ )
1153
1173
 
1154
1174
 
1155
1175
  # ───────── Adapter facade ─────────
@@ -212,7 +212,15 @@ def _cmd_register(args: argparse.Namespace) -> int:
212
212
  return 1
213
213
 
214
214
  registry_path = Path(args.registry) if args.registry else default_registry_path()
215
- registry = Registry.load(registry_path)
215
+ try:
216
+ registry = Registry.load(registry_path)
217
+ except Exception as e:
218
+ print(
219
+ f"error: failed to load registry {registry_path}: {e}\n"
220
+ f"Fix or delete the file and re-run.",
221
+ file=sys.stderr,
222
+ )
223
+ return 1
216
224
  replaced = registry.upsert(entry)
217
225
  registry.save(registry_path)
218
226
 
@@ -85,12 +85,18 @@ async def _start_temporal(
85
85
  ) -> dict[str, Any]:
86
86
  client = await _temporal_client(trigger)
87
87
  workflow_id = f"{tool_name}-{uuid.uuid4().hex[:12]}"
88
- handle = await client.start_workflow(
89
- trigger.workflow_name,
90
- payload,
91
- id=workflow_id,
92
- task_queue=trigger.task_queue,
93
- )
88
+ try:
89
+ handle = await client.start_workflow(
90
+ trigger.workflow_name,
91
+ payload,
92
+ id=workflow_id,
93
+ task_queue=trigger.task_queue,
94
+ )
95
+ except Exception as e:
96
+ raise BackendError(
97
+ f"Temporal at {trigger.address} refused to start workflow "
98
+ f"{trigger.workflow_name!r} on task queue {trigger.task_queue!r}: {e}"
99
+ ) from e
94
100
  return {
95
101
  "workflow_id": workflow_id,
96
102
  "run_id": handle.first_execution_run_id or "",
@@ -102,7 +108,12 @@ async def _start_temporal(
102
108
  async def _status_temporal(trigger: TemporalTrigger, workflow_id: str) -> dict[str, Any]:
103
109
  client = await _temporal_client(trigger)
104
110
  handle = client.get_workflow_handle(workflow_id)
105
- description = await handle.describe()
111
+ try:
112
+ description = await handle.describe()
113
+ except Exception as e:
114
+ raise BackendError(
115
+ f"Temporal at {trigger.address} could not describe workflow {workflow_id!r}: {e}"
116
+ ) from e
106
117
  status = description.status.name.lower() if description.status else "unknown"
107
118
  return {
108
119
  "workflow_id": workflow_id,
@@ -124,10 +135,24 @@ async def _start_cloudflare(trigger: CloudflareTrigger, payload: dict[str, Any])
124
135
  data = resp.json()
125
136
  except httpx.HTTPError as e:
126
137
  raise BackendError(f"Cloudflare worker at {trigger.url} failed: {e}") from e
138
+ except ValueError as e:
139
+ raise BackendError(
140
+ f"Cloudflare worker at {trigger.url} returned a non-JSON response "
141
+ f"(HTTP {resp.status_code}): {resp.text[:200]!r}"
142
+ ) from e
127
143
 
128
- # The emitted src/index.ts responds with {id, status}.
144
+ # The emitted src/index.ts responds with {id, status}. A 2xx JSON body
145
+ # without an id means the URL is not the emitted worker (an auth proxy,
146
+ # a different service) — nothing started, so say so instead of
147
+ # fabricating success with an empty workflow_id.
148
+ if not isinstance(data, dict) or not data.get("id"):
149
+ raise BackendError(
150
+ f"Cloudflare worker at {trigger.url} did not return a workflow id. "
151
+ f"Expected the emitted worker's {{id, status}} response, got: "
152
+ f"{str(data)[:200]}"
153
+ )
129
154
  return {
130
- "workflow_id": str(data.get("id", "")),
155
+ "workflow_id": str(data["id"]),
131
156
  "status": "started",
132
157
  "runtime": "cloudflare",
133
158
  "details": data.get("status"),
@@ -153,13 +153,11 @@ class Registry(BaseModel):
153
153
  def input_schema_for(pipeline_input: PipelineInput) -> dict[str, Any]:
154
154
  """Derive the MCP tool's inputSchema from the pipeline's input contract.
155
155
 
156
- Prefers a structured ``input_schema`` field if the loaded model carries
157
- one (being added to :class:`rote.ir.PipelineInput` concurrently coded
158
- defensively via ``getattr`` so this works before and after that lands).
159
- Otherwise synthesizes a permissive object schema from the required /
156
+ Prefers the structured ``input_schema`` field when the pipeline carries
157
+ one. Otherwise synthesizes a permissive object schema from the required /
160
158
  optional name lists, which are untyped in the current IR.
161
159
  """
162
- explicit = getattr(pipeline_input, "input_schema", None)
160
+ explicit = pipeline_input.input_schema
163
161
  if isinstance(explicit, dict) and explicit:
164
162
  return dict(explicit)
165
163
 
@@ -191,7 +189,13 @@ def entry_from_pipeline(
191
189
  characters replaced by ``-``; description comes from
192
190
  ``pipeline.description`` (first paragraph, whitespace-normalized).
193
191
  """
194
- tool_name = name if name is not None else re.sub(r"[^A-Za-z0-9_-]+", "-", pipeline.name)
192
+ tool_name = (
193
+ name
194
+ if name is not None
195
+ # Strip leading separators the substitution can leave behind — tool
196
+ # names must start alphanumeric or RegistryEntry validation raises.
197
+ else re.sub(r"[^A-Za-z0-9_-]+", "-", pipeline.name).lstrip("-_") or "pipeline"
198
+ )
195
199
  return RegistryEntry(
196
200
  name=tool_name,
197
201
  description=pipeline.description.strip(),
@@ -162,12 +162,22 @@ class RegistryProvider(Provider):
162
162
  return None
163
163
 
164
164
  def _load_if_changed(self) -> bool:
165
- """Reload the registry if the file content changed. Returns True on change."""
165
+ """Reload the registry if the file content changed. Returns True on change.
166
+
167
+ The digest is committed only after a successful parse: a corrupt
168
+ file (hand-edit typo, non-atomic write by another tool) must not
169
+ be recorded as "seen", or the server would silently serve the
170
+ previous tool list forever. Leaving the digest uncommitted means
171
+ every subsequent poll retries, so fixing the file recovers the
172
+ server without a restart. `Registry.save` itself is atomic
173
+ (tmp + rename), so `rote register` can never produce a torn read.
174
+ """
166
175
  digest = self._current_digest()
167
176
  if digest == self._digest:
168
177
  return False
178
+ registry = Registry.load(self.registry_path)
169
179
  self._digest = digest
170
- self._registry = Registry.load(self.registry_path)
180
+ self._registry = registry
171
181
  return True
172
182
 
173
183
  # ── Provider hooks ──
@@ -187,13 +197,28 @@ class RegistryProvider(Provider):
187
197
  yield
188
198
  finally:
189
199
  watcher.cancel()
200
+ with contextlib.suppress(asyncio.CancelledError):
201
+ await watcher
190
202
 
191
203
  # ── change notification ──
192
204
 
193
205
  async def _watch_registry(self) -> None:
194
206
  while True:
195
207
  await asyncio.sleep(self.poll_interval)
196
- if self._load_if_changed():
208
+ try:
209
+ changed = self._load_if_changed()
210
+ except Exception:
211
+ # A corrupt registry or transient filesystem error must not
212
+ # kill the watcher task — that would silently end
213
+ # list_changed notifications for the rest of the server's
214
+ # life. The digest was not committed, so the next poll
215
+ # retries and a fixed file recovers automatically.
216
+ logger.warning(
217
+ "registry reload failed; still serving the previous tool list",
218
+ exc_info=True,
219
+ )
220
+ continue
221
+ if changed:
197
222
  await self._notify_tool_list_changed()
198
223
 
199
224
  async def _notify_tool_list_changed(self) -> None:
File without changes
File without changes
File without changes
File without changes