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.
- {rote_cli-0.1.0 → rote_cli-0.3.0}/PKG-INFO +37 -3
- {rote_cli-0.1.0 → rote_cli-0.3.0}/README.md +36 -2
- {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/__init__.py +1 -1
- {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/adapters/_common.py +4 -1
- {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/adapters/cloudflare.py +181 -1
- {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/adapters/dbos.py +26 -6
- {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/cli.py +9 -1
- {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/serve/backends.py +34 -9
- {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/serve/registry.py +10 -6
- {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/serve/server.py +28 -3
- {rote_cli-0.1.0 → rote_cli-0.3.0}/.gitignore +0 -0
- {rote_cli-0.1.0 → rote_cli-0.3.0}/LICENSE +0 -0
- {rote_cli-0.1.0 → rote_cli-0.3.0}/pyproject.toml +0 -0
- {rote_cli-0.1.0 → rote_cli-0.3.0}/skills/rote-graduate/SKILL.md +0 -0
- {rote_cli-0.1.0 → rote_cli-0.3.0}/skills/rote-graduate/references/crystallization-heuristics.md +0 -0
- {rote_cli-0.1.0 → rote_cli-0.3.0}/skills/rote-graduate/references/ir-schema.md +0 -0
- {rote_cli-0.1.0 → rote_cli-0.3.0}/skills/rote-graduate/references/llm-judge-extraction.md +0 -0
- {rote_cli-0.1.0 → rote_cli-0.3.0}/skills/rote-graduate/references/node-kinds.md +0 -0
- {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/adapters/__init__.py +0 -0
- {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/adapters/temporal.py +0 -0
- {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/graduator/__init__.py +0 -0
- {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/graduator/drivers/__init__.py +0 -0
- {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/graduator/drivers/anthropic_api.py +0 -0
- {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/graduator/drivers/claude.py +0 -0
- {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/graduator/drivers/codex.py +0 -0
- {rote_cli-0.1.0 → rote_cli-0.3.0}/src/rote/ir.py +0 -0
- {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.
|
|
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
|
-
###
|
|
245
|
+
### Use from Claude Code (recommended)
|
|
246
246
|
|
|
247
|
-
`rote`
|
|
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
|
-
###
|
|
199
|
+
### Use from Claude Code (recommended)
|
|
200
200
|
|
|
201
|
-
`rote`
|
|
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
|
|
@@ -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
|
-
|
|
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)
|
|
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_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
|
|
531
|
-
|
|
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
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
157
|
-
one
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
{rote_cli-0.1.0 → rote_cli-0.3.0}/skills/rote-graduate/references/crystallization-heuristics.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|