maestro-case-kit 0.1.0__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.
- maestro_case_kit/__init__.py +9 -0
- maestro_case_kit/cli.py +178 -0
- maestro_case_kit/contribution.py +83 -0
- maestro_case_kit/data/knowledge.json +187 -0
- maestro_case_kit/knowledge.py +150 -0
- maestro_case_kit/mcp_server.py +94 -0
- maestro_case_kit/tools.py +106 -0
- maestro_case_kit/validators.py +264 -0
- maestro_case_kit-0.1.0.dist-info/METADATA +65 -0
- maestro_case_kit-0.1.0.dist-info/RECORD +12 -0
- maestro_case_kit-0.1.0.dist-info/WHEEL +4 -0
- maestro_case_kit-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Maestro Case Kit — agent-native knowledge + offline validators for UiPath Maestro Case.
|
|
2
|
+
|
|
3
|
+
A living, version-stamped knowledge layer over the undocumented Maestro Case /
|
|
4
|
+
Data Fabric / Action Center footguns surfaced while building a multi-stakeholder
|
|
5
|
+
crisis case, plus credential-free static validators that run in CI. No UiPath
|
|
6
|
+
login required for the v1 surface.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
maestro_case_kit/cli.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""`maestro-case` CLI — agent-native, offline access to the knowledge layer.
|
|
2
|
+
|
|
3
|
+
v1 surface: `explain`. Validators (`lint`, `check-spawn`, `check-df`) attach to the
|
|
4
|
+
same parser in later slices. Every subcommand supports ``--json`` for agent/CI use
|
|
5
|
+
and exits non-zero when it has a finding to report.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import IO
|
|
15
|
+
|
|
16
|
+
from . import __version__, contribution, knowledge, validators
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _print_human(entries: list[knowledge.KnowledgeEntry], stream: IO[str]) -> None:
|
|
20
|
+
for e in entries:
|
|
21
|
+
signals = ", ".join(e.error_signatures) or "(no error code — silent behavior)"
|
|
22
|
+
resolved = f" resolved_in: {e.resolved_in}" if e.resolved_in else ""
|
|
23
|
+
print(f"[{e.id}] {e.title}", file=stream)
|
|
24
|
+
print(f" surface: {e.surface}", file=stream)
|
|
25
|
+
print(f" signals: {signals}", file=stream)
|
|
26
|
+
print(f" cause: {e.cause}", file=stream)
|
|
27
|
+
print(f" fix: {e.fix}", file=stream)
|
|
28
|
+
print(f" proven_on: {e.proven_on}{resolved}", file=stream)
|
|
29
|
+
if e.references:
|
|
30
|
+
print(f" refs: {', '.join(e.references)}", file=stream)
|
|
31
|
+
print("", file=stream)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _cmd_explain(args: argparse.Namespace) -> int:
|
|
35
|
+
hits = knowledge.find(args.query, include_resolved=args.include_resolved)
|
|
36
|
+
if args.json:
|
|
37
|
+
print(json.dumps([e.to_dict() for e in hits], indent=2))
|
|
38
|
+
return 0 if hits else 1
|
|
39
|
+
if not hits:
|
|
40
|
+
print(
|
|
41
|
+
f"No known Maestro Case footgun matches {args.query!r}. "
|
|
42
|
+
f"Try an error code (e.g. 400300) or a keyword (e.g. underscore, gate, deploy).",
|
|
43
|
+
file=sys.stderr,
|
|
44
|
+
)
|
|
45
|
+
return 1
|
|
46
|
+
_print_human(hits, sys.stdout)
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _emit_findings(findings: list[validators.Finding], as_json: bool, ok_label: str) -> int:
|
|
51
|
+
if as_json:
|
|
52
|
+
print(json.dumps([f.to_dict() for f in findings], indent=2))
|
|
53
|
+
return 1 if findings else 0
|
|
54
|
+
if not findings:
|
|
55
|
+
print(f"OK — {ok_label}")
|
|
56
|
+
return 0
|
|
57
|
+
for f in findings:
|
|
58
|
+
suffix = f" (explain: {f.entry_id})" if f.entry_id else ""
|
|
59
|
+
where = f" @ {f.location}" if f.location else ""
|
|
60
|
+
print(f"[{f.severity.upper()}] {f.rule_id}: {f.message}{where}{suffix}")
|
|
61
|
+
print(f"\n{len(findings)} finding(s).")
|
|
62
|
+
return 1
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _cmd_lint(args: argparse.Namespace) -> int:
|
|
66
|
+
findings = validators.lint_caseplan(args.caseplan_dir)
|
|
67
|
+
return _emit_findings(findings, args.json, f"no Maestro Case footguns in {args.caseplan_dir}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _cmd_check_spawn(args: argparse.Namespace) -> int:
|
|
71
|
+
findings = validators.check_spawn_fanout(args.caseplan_dir)
|
|
72
|
+
return _emit_findings(findings, args.json, f"no qem spawn-fanout issues in {args.caseplan_dir}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _cmd_check_df(args: argparse.Namespace) -> int:
|
|
76
|
+
findings = validators.validate_df_entity(args.spec)
|
|
77
|
+
return _emit_findings(findings, args.json, f"no Data Fabric field-name traps in {args.spec}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _cmd_validate_knowledge(args: argparse.Namespace) -> int:
|
|
81
|
+
denylist: list[str] = []
|
|
82
|
+
if args.denylist_file:
|
|
83
|
+
for line in Path(args.denylist_file).read_text(encoding="utf-8").splitlines():
|
|
84
|
+
token = line.strip()
|
|
85
|
+
if token and not token.startswith("#"):
|
|
86
|
+
denylist.append(token)
|
|
87
|
+
data: object = args.file or {"entries": [e.to_dict() for e in knowledge.load_entries()]}
|
|
88
|
+
problems = contribution.validate_knowledge(data, denylist)
|
|
89
|
+
if args.json:
|
|
90
|
+
print(json.dumps(problems, indent=2))
|
|
91
|
+
return 1 if problems else 0
|
|
92
|
+
if not problems:
|
|
93
|
+
print("OK — knowledge passes the contribution gate.")
|
|
94
|
+
return 0
|
|
95
|
+
for problem in problems:
|
|
96
|
+
print(f"[GATE] {problem}")
|
|
97
|
+
print(f"\n{len(problems)} problem(s).")
|
|
98
|
+
return 1
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
102
|
+
parser = argparse.ArgumentParser(
|
|
103
|
+
prog="maestro-case",
|
|
104
|
+
description="Knowledge + offline validators for UiPath Maestro Case footguns.",
|
|
105
|
+
)
|
|
106
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
107
|
+
sub = parser.add_subparsers(dest="command")
|
|
108
|
+
|
|
109
|
+
explain = sub.add_parser(
|
|
110
|
+
"explain",
|
|
111
|
+
help="Explain a UiPath Maestro Case error code or footgun from the knowledge layer.",
|
|
112
|
+
)
|
|
113
|
+
explain.add_argument(
|
|
114
|
+
"query",
|
|
115
|
+
help="An error code/signature (e.g. 400300, 160009) or a keyword (e.g. underscore, deploy).",
|
|
116
|
+
)
|
|
117
|
+
explain.add_argument("--json", action="store_true", help="Emit structured JSON.")
|
|
118
|
+
explain.add_argument(
|
|
119
|
+
"--include-resolved",
|
|
120
|
+
action="store_true",
|
|
121
|
+
help="Include entries marked resolved in a later platform version.",
|
|
122
|
+
)
|
|
123
|
+
explain.set_defaults(func=_cmd_explain)
|
|
124
|
+
|
|
125
|
+
lint = sub.add_parser(
|
|
126
|
+
"lint",
|
|
127
|
+
help="Statically lint a caseplan directory for known footguns (offline, no login).",
|
|
128
|
+
)
|
|
129
|
+
lint.add_argument(
|
|
130
|
+
"caseplan_dir",
|
|
131
|
+
help="Path to a directory containing caseplan.json (and ideally caseplan.json.bpmn).",
|
|
132
|
+
)
|
|
133
|
+
lint.add_argument("--json", action="store_true", help="Emit structured JSON findings.")
|
|
134
|
+
lint.set_defaults(func=_cmd_lint)
|
|
135
|
+
|
|
136
|
+
check_spawn = sub.add_parser(
|
|
137
|
+
"check-spawn",
|
|
138
|
+
help="Flag =datafabric.qem expressions in spawn inputs (fail at runtime, 400300).",
|
|
139
|
+
)
|
|
140
|
+
check_spawn.add_argument("caseplan_dir", help="Path to a directory containing caseplan.json.")
|
|
141
|
+
check_spawn.add_argument("--json", action="store_true", help="Emit structured JSON findings.")
|
|
142
|
+
check_spawn.set_defaults(func=_cmd_check_spawn)
|
|
143
|
+
|
|
144
|
+
check_df = sub.add_parser(
|
|
145
|
+
"check-df",
|
|
146
|
+
help="Lint a Data Fabric entity/field spec for silent-drop and reserved-name traps.",
|
|
147
|
+
)
|
|
148
|
+
check_df.add_argument("spec", help="Path to a JSON entity spec ({\"fields\": [...]}).")
|
|
149
|
+
check_df.add_argument("--json", action="store_true", help="Emit structured JSON findings.")
|
|
150
|
+
check_df.set_defaults(func=_cmd_check_df)
|
|
151
|
+
|
|
152
|
+
vk = sub.add_parser(
|
|
153
|
+
"validate-knowledge",
|
|
154
|
+
help="Validate a knowledge file against the schema + an optional IP-safety denylist.",
|
|
155
|
+
)
|
|
156
|
+
vk.add_argument("--file", help="Path to a knowledge JSON file (default: the bundled layer).")
|
|
157
|
+
vk.add_argument(
|
|
158
|
+
"--denylist-file",
|
|
159
|
+
help="Optional newline-delimited file of forbidden tokens (# comments allowed).",
|
|
160
|
+
)
|
|
161
|
+
vk.add_argument("--json", action="store_true", help="Emit structured JSON problems.")
|
|
162
|
+
vk.set_defaults(func=_cmd_validate_knowledge)
|
|
163
|
+
return parser
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def main(argv: list[str] | None = None) -> int:
|
|
167
|
+
parser = build_parser()
|
|
168
|
+
args = parser.parse_args(argv)
|
|
169
|
+
if not getattr(args, "command", None):
|
|
170
|
+
parser.print_help(sys.stderr)
|
|
171
|
+
return 2
|
|
172
|
+
func = args.func
|
|
173
|
+
assert callable(func)
|
|
174
|
+
return int(func(args))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__": # pragma: no cover
|
|
178
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Contribution gate — validates knowledge entries against the schema and an
|
|
2
|
+
optional IP-safety denylist, so curation (not the distribution channel) is the moat.
|
|
3
|
+
|
|
4
|
+
A contributed entry must be well-formed and free of any caller-supplied forbidden
|
|
5
|
+
tokens (e.g. real company names). The denylist is a parameter, not hardcoded, so the
|
|
6
|
+
toolkit stays a general public artifact; a host repo passes its own denylist in CI.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
REQUIRED_FIELDS: tuple[str, ...] = (
|
|
16
|
+
"id",
|
|
17
|
+
"kind",
|
|
18
|
+
"title",
|
|
19
|
+
"surface",
|
|
20
|
+
"symptom",
|
|
21
|
+
"cause",
|
|
22
|
+
"fix",
|
|
23
|
+
"proven_on",
|
|
24
|
+
"severity",
|
|
25
|
+
)
|
|
26
|
+
VALID_SEVERITY: frozenset[str] = frozenset({"high", "medium", "low"})
|
|
27
|
+
_LIST_FIELDS: tuple[str, ...] = ("error_signatures", "references")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _entry_text(entry: dict) -> str:
|
|
31
|
+
parts: list[str] = []
|
|
32
|
+
for value in entry.values():
|
|
33
|
+
if isinstance(value, str):
|
|
34
|
+
parts.append(value)
|
|
35
|
+
elif isinstance(value, list):
|
|
36
|
+
parts.extend(str(item) for item in value)
|
|
37
|
+
return " ".join(parts).lower()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def validate_entry(entry: dict, denylist: Iterable[str] = ()) -> list[str]:
|
|
41
|
+
"""Return a list of problems with one entry; empty means it passes the gate."""
|
|
42
|
+
problems: list[str] = []
|
|
43
|
+
for field in REQUIRED_FIELDS:
|
|
44
|
+
if not entry.get(field):
|
|
45
|
+
problems.append(f"missing or empty required field: {field}")
|
|
46
|
+
severity = entry.get("severity")
|
|
47
|
+
if severity is not None and severity not in VALID_SEVERITY:
|
|
48
|
+
problems.append(f"invalid severity {severity!r} (expected one of {sorted(VALID_SEVERITY)})")
|
|
49
|
+
for field in _LIST_FIELDS:
|
|
50
|
+
if field in entry and not isinstance(entry[field], list):
|
|
51
|
+
problems.append(f"{field} must be a list")
|
|
52
|
+
text = _entry_text(entry)
|
|
53
|
+
entry_id = entry.get("id", "<unknown>")
|
|
54
|
+
for token in denylist:
|
|
55
|
+
if token and token.lower() in text:
|
|
56
|
+
problems.append(f"forbidden token {token!r} in entry {entry_id}")
|
|
57
|
+
return problems
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _coerce_entries(data: object) -> list[dict]:
|
|
61
|
+
if isinstance(data, (str, Path)):
|
|
62
|
+
data = json.loads(Path(data).read_text(encoding="utf-8"))
|
|
63
|
+
if isinstance(data, dict):
|
|
64
|
+
entries = data.get("entries", [])
|
|
65
|
+
else:
|
|
66
|
+
entries = data
|
|
67
|
+
return [e for e in entries if isinstance(e, dict)] if isinstance(entries, list) else []
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def validate_knowledge(data: object, denylist: Iterable[str] = ()) -> list[str]:
|
|
71
|
+
"""Validate a knowledge collection (path, {"entries": [...]}, or a list)."""
|
|
72
|
+
entries = _coerce_entries(data)
|
|
73
|
+
denylist = tuple(denylist)
|
|
74
|
+
problems: list[str] = []
|
|
75
|
+
ids: list[str] = []
|
|
76
|
+
for entry in entries:
|
|
77
|
+
problems.extend(validate_entry(entry, denylist))
|
|
78
|
+
entry_id = entry.get("id")
|
|
79
|
+
if isinstance(entry_id, str):
|
|
80
|
+
ids.append(entry_id)
|
|
81
|
+
for dup in sorted({i for i in ids if ids.count(i) > 1}):
|
|
82
|
+
problems.append(f"duplicate id: {dup}")
|
|
83
|
+
return problems
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"id": "MC-SPAWN-QEM-400300",
|
|
6
|
+
"kind": "runtime",
|
|
7
|
+
"title": "datafabric.qem expression fails in case spawn inputs",
|
|
8
|
+
"surface": "Maestro Case spawn JobArguments",
|
|
9
|
+
"symptom": "A case spawn whose JobArguments use a =datafabric.qem: query expression fails at runtime instead of fanning out.",
|
|
10
|
+
"error_signatures": ["400300", "Syntax error at index 4"],
|
|
11
|
+
"cause": "=datafabric.qem: query expressions are not evaluated in case-spawn JobArguments; they raise a runtime syntax error rather than resolving to data.",
|
|
12
|
+
"fix": "Pass a literal stakeholder slug instead (e.g. StakeholderId: \"gamma\"). Do not attempt data-driven fan-out via qem in spawn inputs; resolve the values at authoring time.",
|
|
13
|
+
"proven_on": "1.0.21",
|
|
14
|
+
"resolved_in": null,
|
|
15
|
+
"severity": "high",
|
|
16
|
+
"references": ["docs/submission/PRODUCT-FEEDBACK.md", "scripts/merge-canvas-download.py"]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": "MC-BPMN-STALE",
|
|
20
|
+
"kind": "runtime",
|
|
21
|
+
"title": "Stale caseplan.json.bpmn runs old logic; only the canvas recompiles it",
|
|
22
|
+
"surface": "Maestro Case build/deploy",
|
|
23
|
+
"symptom": "Edits to caseplan.json have no runtime effect; the case keeps running old logic with no error.",
|
|
24
|
+
"error_signatures": [],
|
|
25
|
+
"cause": "The engine executes the compiled caseplan.json.bpmn, not caseplan.json. Only opening and saving the case in the Studio Web canvas regenerates the .bpmn; CLI upload/pack do not recompile it.",
|
|
26
|
+
"fix": "After any caseplan edit: open the case in the canvas, run uip solution download --extract, commit the regenerated .bpmn, then pack/deploy. Detect staleness by comparing mtimes (caseplan.json newer than .bpmn = stale).",
|
|
27
|
+
"proven_on": "1.0.31",
|
|
28
|
+
"resolved_in": null,
|
|
29
|
+
"severity": "high",
|
|
30
|
+
"references": ["scripts/merge-canvas-download.py", "docs/submission/PRODUCT-FEEDBACK.md"]
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "MC-JOB-CASE-SYNC",
|
|
34
|
+
"kind": "runtime",
|
|
35
|
+
"title": "Completed case instance leaves its Orchestrator job Running",
|
|
36
|
+
"surface": "Orchestrator jobs vs Maestro Case instances",
|
|
37
|
+
"symptom": "A case instance shows Completed but its backing Orchestrator job stays Running for hours or days, breaking job-based completion polling.",
|
|
38
|
+
"error_signatures": [],
|
|
39
|
+
"cause": "Completed Maestro Case instances do not flip their backing job to Successful (only Cancelled instances flip the job, to Stopped). The two views are not reconciled.",
|
|
40
|
+
"fix": "Treat case instances as the source of truth: verify completion with uip maestro case instance get/list, not the Jobs view. Sweep zombie Running jobs separately.",
|
|
41
|
+
"proven_on": "1.0.23",
|
|
42
|
+
"resolved_in": null,
|
|
43
|
+
"severity": "medium",
|
|
44
|
+
"references": ["docs/submission/PRODUCT-FEEDBACK.md", "agents/case-job-janitor"]
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"id": "DF-RESERVED-ID",
|
|
48
|
+
"kind": "data-fabric",
|
|
49
|
+
"title": "Data Fabric rejects a field named id",
|
|
50
|
+
"surface": "Data Fabric entity creation",
|
|
51
|
+
"symptom": "Creating an entity with a field named id is rejected.",
|
|
52
|
+
"error_signatures": ["Invalid field name 'id'", "field name 'id'"],
|
|
53
|
+
"cause": "id collides with the system Id UUID primary key.",
|
|
54
|
+
"fix": "Use an alternate name such as slug instead of id for your business key.",
|
|
55
|
+
"proven_on": "1.0.x",
|
|
56
|
+
"resolved_in": null,
|
|
57
|
+
"severity": "medium",
|
|
58
|
+
"references": ["scripts/seed_data_fabric.py", "docs/submission/PRODUCT-FEEDBACK.md"]
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"id": "DF-UNDERSCORE-DROP",
|
|
62
|
+
"kind": "data-fabric",
|
|
63
|
+
"title": "Underscore field names are silently dropped on insert",
|
|
64
|
+
"surface": "Data Fabric records insert",
|
|
65
|
+
"symptom": "Field names containing underscores (provider_id, display_name) are accepted by the schema and the insert reports success, but the values never persist. No error is surfaced.",
|
|
66
|
+
"error_signatures": [],
|
|
67
|
+
"cause": "uip df records insert silently drops fields whose names contain underscores; only the live-layer camelCase form persists.",
|
|
68
|
+
"fix": "camelCase every field name on insert (provider_id -> providerId, display_name -> displayName). Keep snake_case only in schema docs, never in the inserted payload.",
|
|
69
|
+
"proven_on": "1.0.x",
|
|
70
|
+
"resolved_in": null,
|
|
71
|
+
"severity": "high",
|
|
72
|
+
"references": ["scripts/seed_data_fabric.py", "docs/submission/PRODUCT-FEEDBACK.md"]
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"id": "DF-ENTITY-NAME-NOT-GUID",
|
|
76
|
+
"kind": "data-fabric",
|
|
77
|
+
"title": "records insert needs the entity GUID, not its name",
|
|
78
|
+
"surface": "Data Fabric records insert",
|
|
79
|
+
"symptom": "Passing an entity name to records insert is rejected.",
|
|
80
|
+
"error_signatures": ["is not valid", "not valid"],
|
|
81
|
+
"cause": "uip df records insert expects the entity GUID returned by entities create/list, not the human-readable entity name.",
|
|
82
|
+
"fix": "Run uip df entities list first, build a name->GUID map, and pass the GUID to every insert.",
|
|
83
|
+
"proven_on": "1.0.x",
|
|
84
|
+
"resolved_in": null,
|
|
85
|
+
"severity": "low",
|
|
86
|
+
"references": ["scripts/seed_data_fabric.py"]
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"id": "HITL-GATE-DELETE-160009",
|
|
90
|
+
"kind": "hitl",
|
|
91
|
+
"title": "Deleting a pending HITL gate faults the case (160009)",
|
|
92
|
+
"surface": "Action Center HITL gate tasks",
|
|
93
|
+
"symptom": "Deleting (instead of actioning) a pending gate task faults the backing case instance.",
|
|
94
|
+
"error_signatures": ["160009"],
|
|
95
|
+
"cause": "A deleted gate task is recorded as a user-caused incident (ErrorCode 160009, IncidentType User) pinned to the gate element; the case transitions to Faulted.",
|
|
96
|
+
"fix": "Always ACTION gate tasks (Approve/Deny, File/Withdraw) — never delete them. Add a confirmation/guard in any operator UI that exposes delete.",
|
|
97
|
+
"proven_on": "1.0.32",
|
|
98
|
+
"resolved_in": null,
|
|
99
|
+
"severity": "medium",
|
|
100
|
+
"references": ["docs/submission/PRODUCT-FEEDBACK.md"]
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"id": "HITL-APPTASK-405",
|
|
104
|
+
"kind": "hitl",
|
|
105
|
+
"title": "AppTask completion returns 405 on the obvious endpoints",
|
|
106
|
+
"surface": "Action Center AppTask completion (REST)",
|
|
107
|
+
"symptom": "Completing an AppTask via the obvious OData/form endpoints returns 405 Method Not Allowed.",
|
|
108
|
+
"error_signatures": ["405", "Method Not Allowed"],
|
|
109
|
+
"cause": "The documented OData Complete action and form endpoints do not complete AppTasks; the correct route was undocumented.",
|
|
110
|
+
"fix": "Use POST /tasks/AppTasks/CompleteAppTask (the route behind uip tasks complete --type AppTask). Assign the task first; unassigned tasks fail.",
|
|
111
|
+
"proven_on": "1.0.x",
|
|
112
|
+
"resolved_in": null,
|
|
113
|
+
"severity": "low",
|
|
114
|
+
"references": ["demo_autocomplete.py", "docs/submission/PRODUCT-FEEDBACK.md"]
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"id": "HITL-APPTASK-UNASSIGNED",
|
|
118
|
+
"kind": "hitl",
|
|
119
|
+
"title": "AppTask completion fails when the task is unassigned",
|
|
120
|
+
"surface": "Action Center AppTask completion",
|
|
121
|
+
"symptom": "Completing an AppTask that is not assigned to the caller fails.",
|
|
122
|
+
"error_signatures": ["This action is no longer assigned to you", "no longer assigned"],
|
|
123
|
+
"cause": "AppTask completion requires the task to be assigned to the acting identity first.",
|
|
124
|
+
"fix": "Assign the task before completing it (uip tasks assign <id> --user <email>), then complete.",
|
|
125
|
+
"proven_on": "1.0.x",
|
|
126
|
+
"resolved_in": null,
|
|
127
|
+
"severity": "low",
|
|
128
|
+
"references": ["demo_autocomplete.py"]
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"id": "HITL-APPTASK-ORPHANED",
|
|
132
|
+
"kind": "hitl",
|
|
133
|
+
"title": "AppTasks on a stopped case are orphaned and uncompletable",
|
|
134
|
+
"surface": "Action Center AppTask completion",
|
|
135
|
+
"symptom": "An AppTask whose backing case instance was stopped or swept cannot be completed.",
|
|
136
|
+
"error_signatures": ["This action has been already deleted", "already deleted"],
|
|
137
|
+
"cause": "Stopping or sweeping the backing case instance orphans its AppTasks; they are dead in both CLI and the Action Center UI.",
|
|
138
|
+
"fix": "Complete AppTasks before stopping or sweeping their backing jobs. For a demo, action all gates within the janitor window before any sweep, or start a fresh run.",
|
|
139
|
+
"proven_on": "1.0.x",
|
|
140
|
+
"resolved_in": null,
|
|
141
|
+
"severity": "medium",
|
|
142
|
+
"references": ["demo_autocomplete.py", "docs/submission/PRODUCT-FEEDBACK.md"]
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"id": "CLI-CODEDAPP-INDEXING-HANG",
|
|
146
|
+
"kind": "cli",
|
|
147
|
+
"title": "uip codedapp deploy hangs forever with -v/--path-name",
|
|
148
|
+
"surface": "uip codedapp deploy",
|
|
149
|
+
"symptom": "Deploying a coded app with explicit -v/--path-name hangs indefinitely on an indexing status check and never returns.",
|
|
150
|
+
"error_signatures": ["still being indexed", "has not been published yet"],
|
|
151
|
+
"cause": "The explicit-version deploy path triggers a broken indexing status-check that never completes.",
|
|
152
|
+
"fix": "Use the bare form: uip codedapp deploy -n <name> --folder-key <key>. It reads app.config.json and upgrades in place. Confirm via stdout 'App upgraded successfully' + HTTP 200, not the local config file.",
|
|
153
|
+
"proven_on": "1.0.7",
|
|
154
|
+
"resolved_in": null,
|
|
155
|
+
"severity": "low",
|
|
156
|
+
"references": ["docs/submission/PRODUCT-FEEDBACK.md", "CLAUDE.md"]
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"id": "PKG-ENTRYPOINT-REGEN",
|
|
160
|
+
"kind": "packaging",
|
|
161
|
+
"title": "solution pack regenerates entry-point IDs and breaks upgrades",
|
|
162
|
+
"surface": "uip solution pack / deploy upgrade",
|
|
163
|
+
"symptom": "Upgrading an existing deployment fails because the solution tries to preserve old entry-point IDs that no longer exist.",
|
|
164
|
+
"error_signatures": ["Cannot set entry point", "was not found in package version"],
|
|
165
|
+
"cause": "uip solution pack regenerates entry-point IDs on every repack, so an in-place upgrade cannot match the previously deployed IDs.",
|
|
166
|
+
"fix": "Build a mixed-version package: keep already-deployed Agent-type processes at their stable version and bump only non-Agent projects, swapping payloads and bumping packageVersion + a fresh packageVersionKey.",
|
|
167
|
+
"proven_on": "1.0.32",
|
|
168
|
+
"resolved_in": null,
|
|
169
|
+
"severity": "high",
|
|
170
|
+
"references": ["docs/submission/PRODUCT-FEEDBACK.md"]
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"id": "CG-MD-SKIP",
|
|
174
|
+
"kind": "context-grounding",
|
|
175
|
+
"title": "Context Grounding silently skips .md files",
|
|
176
|
+
"surface": "Context Grounding ingestion",
|
|
177
|
+
"symptom": "Ingesting a Markdown corpus reports Successful, but searches return zero hits.",
|
|
178
|
+
"error_signatures": [],
|
|
179
|
+
"cause": "Markdown is not a supported ingestion format; .md files are skipped silently while the job still reports success.",
|
|
180
|
+
"fix": "Convert the corpus to .txt (or .pdf/.docx) before ingestion, and verify retrieval returns hits after seeding.",
|
|
181
|
+
"proven_on": "1.0.x",
|
|
182
|
+
"resolved_in": null,
|
|
183
|
+
"severity": "medium",
|
|
184
|
+
"references": ["scripts/seed_data_fabric.py", "docs/submission/PRODUCT-FEEDBACK.md"]
|
|
185
|
+
}
|
|
186
|
+
]
|
|
187
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""The versioned knowledge layer: load, query, and freshness-filter footgun entries.
|
|
2
|
+
|
|
3
|
+
Each entry records the platform/CLI version it was proven on and an optional
|
|
4
|
+
``resolved_in`` version. When UiPath fixes a footgun, set ``resolved_in`` and the
|
|
5
|
+
entry self-deprecates into history instead of misinforming — the freshness risk
|
|
6
|
+
becomes a feature (see the requirements doc, KD6).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass, replace
|
|
13
|
+
from importlib.resources import files
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
_DATA_PACKAGE = "maestro_case_kit.data"
|
|
17
|
+
_DATA_FILE = "knowledge.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class KnowledgeEntry:
|
|
22
|
+
"""One discovered Maestro Case / Data Fabric / Action Center footgun."""
|
|
23
|
+
|
|
24
|
+
id: str
|
|
25
|
+
kind: str
|
|
26
|
+
title: str
|
|
27
|
+
surface: str
|
|
28
|
+
symptom: str
|
|
29
|
+
error_signatures: tuple[str, ...]
|
|
30
|
+
cause: str
|
|
31
|
+
fix: str
|
|
32
|
+
proven_on: str
|
|
33
|
+
severity: str
|
|
34
|
+
references: tuple[str, ...]
|
|
35
|
+
resolved_in: str | None = None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def is_active(self) -> bool:
|
|
39
|
+
"""True until UiPath ships a fix and ``resolved_in`` is set."""
|
|
40
|
+
return self.resolved_in is None
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict[str, object]:
|
|
43
|
+
return {
|
|
44
|
+
"id": self.id,
|
|
45
|
+
"kind": self.kind,
|
|
46
|
+
"title": self.title,
|
|
47
|
+
"surface": self.surface,
|
|
48
|
+
"symptom": self.symptom,
|
|
49
|
+
"error_signatures": list(self.error_signatures),
|
|
50
|
+
"cause": self.cause,
|
|
51
|
+
"fix": self.fix,
|
|
52
|
+
"proven_on": self.proven_on,
|
|
53
|
+
"resolved_in": self.resolved_in,
|
|
54
|
+
"severity": self.severity,
|
|
55
|
+
"references": list(self.references),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _normalize(text: str) -> str:
|
|
60
|
+
return " ".join(text.casefold().split())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _read_raw(path: Path | None) -> dict[str, object]:
|
|
64
|
+
if path is None:
|
|
65
|
+
text = files(_DATA_PACKAGE).joinpath(_DATA_FILE).read_text(encoding="utf-8")
|
|
66
|
+
else:
|
|
67
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
68
|
+
return json.loads(text)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_entries(path: Path | None = None) -> list[KnowledgeEntry]:
|
|
72
|
+
"""Load every knowledge entry from the bundled data file (or a given path)."""
|
|
73
|
+
raw = _read_raw(path)
|
|
74
|
+
items = raw["entries"]
|
|
75
|
+
assert isinstance(items, list)
|
|
76
|
+
entries: list[KnowledgeEntry] = []
|
|
77
|
+
for item in items:
|
|
78
|
+
entries.append(
|
|
79
|
+
KnowledgeEntry(
|
|
80
|
+
id=item["id"],
|
|
81
|
+
kind=item["kind"],
|
|
82
|
+
title=item["title"],
|
|
83
|
+
surface=item["surface"],
|
|
84
|
+
symptom=item["symptom"],
|
|
85
|
+
error_signatures=tuple(item.get("error_signatures", [])),
|
|
86
|
+
cause=item["cause"],
|
|
87
|
+
fix=item["fix"],
|
|
88
|
+
proven_on=item["proven_on"],
|
|
89
|
+
severity=item["severity"],
|
|
90
|
+
references=tuple(item.get("references", [])),
|
|
91
|
+
resolved_in=item.get("resolved_in"),
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
return entries
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def replace_resolved(entry: KnowledgeEntry, version: str) -> KnowledgeEntry:
|
|
98
|
+
"""Return a copy of ``entry`` marked resolved in ``version`` (test/curation helper)."""
|
|
99
|
+
return replace(entry, resolved_in=version)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def active_entries(entries: list[KnowledgeEntry] | None = None) -> list[KnowledgeEntry]:
|
|
103
|
+
"""Entries that still bite the current platform (``resolved_in`` unset)."""
|
|
104
|
+
pool = load_entries() if entries is None else entries
|
|
105
|
+
return [e for e in pool if e.is_active]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def find_by_error(
|
|
109
|
+
query: str,
|
|
110
|
+
entries: list[KnowledgeEntry] | None = None,
|
|
111
|
+
include_resolved: bool = False,
|
|
112
|
+
) -> list[KnowledgeEntry]:
|
|
113
|
+
"""Match a raw error signature against entries' ``error_signatures``."""
|
|
114
|
+
pool = load_entries() if entries is None else entries
|
|
115
|
+
q = _normalize(query)
|
|
116
|
+
if not q:
|
|
117
|
+
return []
|
|
118
|
+
hits: list[KnowledgeEntry] = []
|
|
119
|
+
for e in pool:
|
|
120
|
+
if not include_resolved and not e.is_active:
|
|
121
|
+
continue
|
|
122
|
+
for sig in e.error_signatures:
|
|
123
|
+
ns = _normalize(sig)
|
|
124
|
+
if ns and (q in ns or ns in q):
|
|
125
|
+
hits.append(e)
|
|
126
|
+
break
|
|
127
|
+
return hits
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def find(
|
|
131
|
+
query: str,
|
|
132
|
+
entries: list[KnowledgeEntry] | None = None,
|
|
133
|
+
include_resolved: bool = False,
|
|
134
|
+
) -> list[KnowledgeEntry]:
|
|
135
|
+
"""Error-signature matches first, then a keyword fallback across entry text."""
|
|
136
|
+
pool = load_entries() if entries is None else entries
|
|
137
|
+
q = _normalize(query)
|
|
138
|
+
if not q:
|
|
139
|
+
return []
|
|
140
|
+
candidates = pool if include_resolved else [e for e in pool if e.is_active]
|
|
141
|
+
sig_hits = find_by_error(query, candidates, include_resolved=include_resolved)
|
|
142
|
+
seen = {e.id for e in sig_hits}
|
|
143
|
+
keyword_hits: list[KnowledgeEntry] = []
|
|
144
|
+
for e in candidates:
|
|
145
|
+
if e.id in seen:
|
|
146
|
+
continue
|
|
147
|
+
blob = _normalize(" ".join([e.id, e.title, e.symptom, e.cause, e.fix, e.surface]))
|
|
148
|
+
if q in blob:
|
|
149
|
+
keyword_hits.append(e)
|
|
150
|
+
return sig_hits + keyword_hits
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""A dependency-free MCP server over stdio (newline-delimited JSON-RPC 2.0).
|
|
2
|
+
|
|
3
|
+
Implements the three methods an agent host needs — initialize, tools/list,
|
|
4
|
+
tools/call — over the shared tool registry. No third-party MCP SDK required, so
|
|
5
|
+
the server runs anywhere Python does and stays testable in CI. Register with a
|
|
6
|
+
Claude Code / OpenClaw host as: `maestro-case-mcp` (stdio).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from typing import IO
|
|
14
|
+
|
|
15
|
+
from . import __version__, tools
|
|
16
|
+
|
|
17
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
18
|
+
SERVER_NAME = "maestro-case-kit"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _result(request_id: object, result: dict) -> dict:
|
|
22
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _error(request_id: object, code: int, message: str) -> dict:
|
|
26
|
+
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def handle_request(request: dict) -> dict | None:
|
|
30
|
+
"""Dispatch one JSON-RPC request. Returns None for notifications (no id)."""
|
|
31
|
+
method = request.get("method")
|
|
32
|
+
request_id = request.get("id")
|
|
33
|
+
if request_id is None:
|
|
34
|
+
return None # notification — no response
|
|
35
|
+
|
|
36
|
+
if method == "initialize":
|
|
37
|
+
return _result(
|
|
38
|
+
request_id,
|
|
39
|
+
{
|
|
40
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
41
|
+
"capabilities": {"tools": {}},
|
|
42
|
+
"serverInfo": {"name": SERVER_NAME, "version": __version__},
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if method == "tools/list":
|
|
47
|
+
listed = [
|
|
48
|
+
{"name": t.name, "description": t.description, "inputSchema": t.input_schema}
|
|
49
|
+
for t in tools.TOOLS
|
|
50
|
+
]
|
|
51
|
+
return _result(request_id, {"tools": listed})
|
|
52
|
+
|
|
53
|
+
if method == "tools/call":
|
|
54
|
+
params = request.get("params") or {}
|
|
55
|
+
name = params.get("name", "")
|
|
56
|
+
arguments = params.get("arguments") or {}
|
|
57
|
+
try:
|
|
58
|
+
output = tools.run_tool(name, arguments)
|
|
59
|
+
return _result(
|
|
60
|
+
request_id,
|
|
61
|
+
{"content": [{"type": "text", "text": json.dumps(output, indent=2)}], "isError": False},
|
|
62
|
+
)
|
|
63
|
+
except Exception as exc: # tool-level failure -> isError, not a protocol error
|
|
64
|
+
return _result(
|
|
65
|
+
request_id,
|
|
66
|
+
{"content": [{"type": "text", "text": f"{type(exc).__name__}: {exc}"}], "isError": True},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return _error(request_id, -32601, f"method not found: {method}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def serve(stdin: IO[str], stdout: IO[str]) -> None:
|
|
73
|
+
"""Read newline-delimited JSON-RPC from stdin; write responses to stdout."""
|
|
74
|
+
for line in stdin:
|
|
75
|
+
line = line.strip()
|
|
76
|
+
if not line:
|
|
77
|
+
continue
|
|
78
|
+
try:
|
|
79
|
+
request = json.loads(line)
|
|
80
|
+
except json.JSONDecodeError:
|
|
81
|
+
continue
|
|
82
|
+
response = handle_request(request)
|
|
83
|
+
if response is not None:
|
|
84
|
+
stdout.write(json.dumps(response) + "\n")
|
|
85
|
+
stdout.flush()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main() -> int:
|
|
89
|
+
serve(sys.stdin, sys.stdout)
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__": # pragma: no cover
|
|
94
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Agent-native tool registry — one definition of the tool surface, shared by the
|
|
2
|
+
CLI and the MCP server. Each tool has a JSON input schema and a handler that
|
|
3
|
+
returns a list of plain dicts (knowledge entries or lint findings).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
from . import knowledge, validators
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Tool:
|
|
16
|
+
name: str
|
|
17
|
+
description: str
|
|
18
|
+
input_schema: dict
|
|
19
|
+
handler: Callable[[dict], list[dict]]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _explain(args: dict) -> list[dict]:
|
|
23
|
+
hits = knowledge.find(str(args["query"]), include_resolved=bool(args.get("include_resolved", False)))
|
|
24
|
+
return [e.to_dict() for e in hits]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _lint(args: dict) -> list[dict]:
|
|
28
|
+
return [f.to_dict() for f in validators.lint_caseplan(str(args["caseplan_dir"]))]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _check_spawn(args: dict) -> list[dict]:
|
|
32
|
+
return [f.to_dict() for f in validators.check_spawn_fanout(str(args["caseplan_dir"]))]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _check_df(args: dict) -> list[dict]:
|
|
36
|
+
return [f.to_dict() for f in validators.validate_df_entity(str(args["spec_path"]))]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _caseplan_dir_schema() -> dict:
|
|
40
|
+
return {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"properties": {
|
|
43
|
+
"caseplan_dir": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"description": "Path to a directory containing caseplan.json.",
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"required": ["caseplan_dir"],
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
TOOLS: list[Tool] = [
|
|
53
|
+
Tool(
|
|
54
|
+
"maestro_case_explain",
|
|
55
|
+
"Explain a UiPath Maestro Case error code or footgun from the offline, "
|
|
56
|
+
"version-stamped knowledge layer. Accepts an error code/signature or a keyword.",
|
|
57
|
+
{
|
|
58
|
+
"type": "object",
|
|
59
|
+
"properties": {
|
|
60
|
+
"query": {"type": "string", "description": "Error code/signature or keyword."},
|
|
61
|
+
"include_resolved": {
|
|
62
|
+
"type": "boolean",
|
|
63
|
+
"description": "Include entries marked resolved in a later platform version.",
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
"required": ["query"],
|
|
67
|
+
},
|
|
68
|
+
_explain,
|
|
69
|
+
),
|
|
70
|
+
Tool(
|
|
71
|
+
"maestro_case_lint",
|
|
72
|
+
"Statically lint a caseplan directory for known footguns (stale .bpmn, missing "
|
|
73
|
+
"start event, duplicate output vars, bad expression prefixes). Offline, no login.",
|
|
74
|
+
_caseplan_dir_schema(),
|
|
75
|
+
_lint,
|
|
76
|
+
),
|
|
77
|
+
Tool(
|
|
78
|
+
"maestro_case_check_spawn",
|
|
79
|
+
"Flag =datafabric.qem expressions in spawn inputs, which fail at runtime (400300).",
|
|
80
|
+
_caseplan_dir_schema(),
|
|
81
|
+
_check_spawn,
|
|
82
|
+
),
|
|
83
|
+
Tool(
|
|
84
|
+
"maestro_case_check_df",
|
|
85
|
+
"Lint a Data Fabric entity/field spec for the underscore silent-drop and the "
|
|
86
|
+
"reserved 'id' field traps.",
|
|
87
|
+
{
|
|
88
|
+
"type": "object",
|
|
89
|
+
"properties": {
|
|
90
|
+
"spec_path": {"type": "string", "description": "Path to a JSON entity spec."}
|
|
91
|
+
},
|
|
92
|
+
"required": ["spec_path"],
|
|
93
|
+
},
|
|
94
|
+
_check_df,
|
|
95
|
+
),
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
_BY_NAME: dict[str, Tool] = {t.name: t for t in TOOLS}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_tool(name: str) -> Tool:
|
|
102
|
+
return _BY_NAME[name]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def run_tool(name: str, args: dict) -> list[dict]:
|
|
106
|
+
return get_tool(name).handler(args)
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Static, credential-free validators for a UiPath Maestro Case caseplan.
|
|
2
|
+
|
|
3
|
+
These run offline against a caseplan directory (caseplan.json + the compiled
|
|
4
|
+
caseplan.json.bpmn) and are safe in CI — no UiPath login. The rules generalize the
|
|
5
|
+
deterministic canvas-round-trip drops that bite Maestro Case authors; rule ids map
|
|
6
|
+
to knowledge-layer entries so `maestro-case explain <rule>` gives the full recipe.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# Allowed V20 caseplan expression prefixes. Seeded from the documented conventions
|
|
17
|
+
# and reconciled against the prefixes that actually appear in live caseplans
|
|
18
|
+
# (=jsonString is real and was missing from the docs). =datafabric is a valid
|
|
19
|
+
# family even though the =datafabric.qem: spawn form fails at runtime — that
|
|
20
|
+
# runtime trap is caught by check_spawn_fanout, not by a prefix rule.
|
|
21
|
+
ALLOWED_EXPR_PREFIXES: tuple[str, ...] = (
|
|
22
|
+
"=vars",
|
|
23
|
+
"=js:",
|
|
24
|
+
"=metadata",
|
|
25
|
+
"=bindings",
|
|
26
|
+
"=datafabric",
|
|
27
|
+
"=response",
|
|
28
|
+
"=string",
|
|
29
|
+
"=jsonString",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_LEGACY_EXPR = re.compile(r"^\$\w")
|
|
33
|
+
_LOOKS_LIKE_EXPR = re.compile(r"^=[A-Za-z]")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Finding:
|
|
38
|
+
rule_id: str
|
|
39
|
+
severity: str
|
|
40
|
+
message: str
|
|
41
|
+
location: str | None = None
|
|
42
|
+
entry_id: str | None = None
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict[str, object]:
|
|
45
|
+
return {
|
|
46
|
+
"rule_id": self.rule_id,
|
|
47
|
+
"severity": self.severity,
|
|
48
|
+
"message": self.message,
|
|
49
|
+
"location": self.location,
|
|
50
|
+
"entry_id": self.entry_id,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _walk_strings(node: object, path: str = "$"):
|
|
55
|
+
"""Yield (json_path, string_value) for every string leaf in the structure."""
|
|
56
|
+
if isinstance(node, str):
|
|
57
|
+
yield path, node
|
|
58
|
+
elif isinstance(node, dict):
|
|
59
|
+
for key, value in node.items():
|
|
60
|
+
yield from _walk_strings(value, f"{path}.{key}")
|
|
61
|
+
elif isinstance(node, list):
|
|
62
|
+
for index, value in enumerate(node):
|
|
63
|
+
yield from _walk_strings(value, f"{path}[{index}]")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _walk_output_lists(node: object, path: str = "$"):
|
|
67
|
+
"""Yield (json_path, outputs_list) for every list found under an 'outputs' key."""
|
|
68
|
+
if isinstance(node, dict):
|
|
69
|
+
for key, value in node.items():
|
|
70
|
+
child_path = f"{path}.{key}"
|
|
71
|
+
if key == "outputs" and isinstance(value, list):
|
|
72
|
+
yield child_path, value
|
|
73
|
+
yield from _walk_output_lists(value, child_path)
|
|
74
|
+
elif isinstance(node, list):
|
|
75
|
+
for index, value in enumerate(node):
|
|
76
|
+
yield from _walk_output_lists(value, f"{path}[{index}]")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _check_bpmn(caseplan_path: Path, bpmn_path: Path) -> list[Finding]:
|
|
80
|
+
findings: list[Finding] = []
|
|
81
|
+
if not bpmn_path.is_file():
|
|
82
|
+
findings.append(
|
|
83
|
+
Finding(
|
|
84
|
+
"MC-NO-BPMN",
|
|
85
|
+
"medium",
|
|
86
|
+
"No compiled caseplan.json.bpmn next to caseplan.json — the engine "
|
|
87
|
+
"runs the compiled .bpmn, so edits may be inert until the canvas "
|
|
88
|
+
"regenerates it. Cannot verify compiled state.",
|
|
89
|
+
location=str(bpmn_path),
|
|
90
|
+
entry_id="MC-BPMN-STALE",
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
return findings
|
|
94
|
+
if caseplan_path.stat().st_mtime > bpmn_path.stat().st_mtime:
|
|
95
|
+
findings.append(
|
|
96
|
+
Finding(
|
|
97
|
+
"MC-BPMN-STALE",
|
|
98
|
+
"high",
|
|
99
|
+
"caseplan.json is newer than its compiled caseplan.json.bpmn — edits "
|
|
100
|
+
"are likely inert at runtime. Regenerate the .bpmn via the Studio Web "
|
|
101
|
+
"canvas before pack/deploy.",
|
|
102
|
+
location=str(bpmn_path),
|
|
103
|
+
entry_id="MC-BPMN-STALE",
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
if bpmn_path.read_text(encoding="utf-8").count("<bpmn:startEvent") == 0:
|
|
107
|
+
findings.append(
|
|
108
|
+
Finding(
|
|
109
|
+
"MC-NO-START-EVENT",
|
|
110
|
+
"high",
|
|
111
|
+
"Compiled .bpmn has no <bpmn:startEvent> — the case will not auto-walk "
|
|
112
|
+
"from its first stage. The canvas commonly drops the mainline start "
|
|
113
|
+
"event on round-trip; restore it before deploy.",
|
|
114
|
+
location=str(bpmn_path),
|
|
115
|
+
entry_id="MC-BPMN-STALE",
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
return findings
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _check_outputs(caseplan: object) -> list[Finding]:
|
|
122
|
+
findings: list[Finding] = []
|
|
123
|
+
for path, outputs in _walk_output_lists(caseplan):
|
|
124
|
+
seen: set[str] = set()
|
|
125
|
+
for output in outputs:
|
|
126
|
+
if not isinstance(output, dict):
|
|
127
|
+
continue
|
|
128
|
+
var = output.get("var")
|
|
129
|
+
if isinstance(var, str) and var:
|
|
130
|
+
if var in seen:
|
|
131
|
+
findings.append(
|
|
132
|
+
Finding(
|
|
133
|
+
"MC-DUP-OUTPUT-VAR",
|
|
134
|
+
"high",
|
|
135
|
+
f"Duplicate output variable {var!r} on one task — V20 rejects "
|
|
136
|
+
f"duplicate output vars (the canvas auto-adds a dup 'error' "
|
|
137
|
+
f"output on round-trip). De-duplicate or suffix from 2.",
|
|
138
|
+
location=path,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
seen.add(var)
|
|
142
|
+
return findings
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# Output/variable binding keys hold =<FieldName> references (e.g. source="=Error",
|
|
146
|
+
# target="=reviewerId"), not value expressions — exempt them from the prefix rule.
|
|
147
|
+
_BINDING_KEYS = (".source", ".target")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _check_expressions(caseplan: object) -> list[Finding]:
|
|
151
|
+
findings: list[Finding] = []
|
|
152
|
+
for path, value in _walk_strings(caseplan):
|
|
153
|
+
if _LEGACY_EXPR.match(value):
|
|
154
|
+
findings.append(
|
|
155
|
+
Finding(
|
|
156
|
+
"MC-LEGACY-EXPR",
|
|
157
|
+
"medium",
|
|
158
|
+
f"Legacy $-prefixed expression {value!r} — V20 uses =-prefixed "
|
|
159
|
+
f"expressions (e.g. =js:vars.x), not $vars.x.",
|
|
160
|
+
location=path,
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
elif path.endswith(_BINDING_KEYS):
|
|
164
|
+
continue
|
|
165
|
+
elif _LOOKS_LIKE_EXPR.match(value) and not value.startswith(ALLOWED_EXPR_PREFIXES):
|
|
166
|
+
findings.append(
|
|
167
|
+
Finding(
|
|
168
|
+
"MC-BAD-EXPR-PREFIX",
|
|
169
|
+
"medium",
|
|
170
|
+
f"Expression {value!r} does not start with an allowed V20 prefix "
|
|
171
|
+
f"({', '.join(ALLOWED_EXPR_PREFIXES)}).",
|
|
172
|
+
location=path,
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
return findings
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _load_caseplan(caseplan_dir: Path | str) -> tuple[object, Path]:
|
|
179
|
+
directory = Path(caseplan_dir)
|
|
180
|
+
caseplan_path = directory / "caseplan.json"
|
|
181
|
+
if not caseplan_path.is_file():
|
|
182
|
+
raise FileNotFoundError(f"no caseplan.json in {directory}")
|
|
183
|
+
return json.loads(caseplan_path.read_text(encoding="utf-8")), caseplan_path
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def lint_caseplan(caseplan_dir: Path | str) -> list[Finding]:
|
|
187
|
+
"""Lint a caseplan directory. Raises FileNotFoundError if caseplan.json is absent."""
|
|
188
|
+
caseplan, caseplan_path = _load_caseplan(caseplan_dir)
|
|
189
|
+
bpmn_path = caseplan_path.parent / "caseplan.json.bpmn"
|
|
190
|
+
findings: list[Finding] = []
|
|
191
|
+
findings.extend(_check_bpmn(caseplan_path, bpmn_path))
|
|
192
|
+
findings.extend(_check_outputs(caseplan))
|
|
193
|
+
findings.extend(_check_expressions(caseplan))
|
|
194
|
+
return findings
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def check_spawn_fanout(caseplan_dir: Path | str) -> list[Finding]:
|
|
198
|
+
"""Flag =datafabric.qem expressions in spawn inputs — they fail at runtime (400300)."""
|
|
199
|
+
caseplan, _ = _load_caseplan(caseplan_dir)
|
|
200
|
+
findings: list[Finding] = []
|
|
201
|
+
for path, value in _walk_strings(caseplan):
|
|
202
|
+
if "=datafabric.qem" in value:
|
|
203
|
+
findings.append(
|
|
204
|
+
Finding(
|
|
205
|
+
"MC-SPAWN-QEM-400300",
|
|
206
|
+
"high",
|
|
207
|
+
f"=datafabric.qem expression {value!r} fails at runtime in spawn "
|
|
208
|
+
f"inputs (400300, 'Syntax error at index 4'). Resolve the value at "
|
|
209
|
+
f"authoring time and pass a literal slug instead.",
|
|
210
|
+
location=path,
|
|
211
|
+
entry_id="MC-SPAWN-QEM-400300",
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
return findings
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _to_camel(name: str) -> str:
|
|
218
|
+
parts = name.split("_")
|
|
219
|
+
return parts[0] + "".join(p[:1].upper() + p[1:] for p in parts[1:] if p)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _extract_field_names(spec: object) -> list[str]:
|
|
223
|
+
fields = spec.get("fields", []) if isinstance(spec, dict) else spec
|
|
224
|
+
names: list[str] = []
|
|
225
|
+
if isinstance(fields, list):
|
|
226
|
+
for field in fields:
|
|
227
|
+
if isinstance(field, str):
|
|
228
|
+
names.append(field)
|
|
229
|
+
elif isinstance(field, dict) and isinstance(field.get("name"), str):
|
|
230
|
+
names.append(field["name"])
|
|
231
|
+
return names
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def validate_df_entity(spec_path: Path | str) -> list[Finding]:
|
|
235
|
+
"""Lint a Data Fabric entity/field spec for silent-drop and reserved-name traps."""
|
|
236
|
+
path = Path(spec_path)
|
|
237
|
+
if not path.is_file():
|
|
238
|
+
raise FileNotFoundError(f"no spec file at {path}")
|
|
239
|
+
spec = json.loads(path.read_text(encoding="utf-8"))
|
|
240
|
+
findings: list[Finding] = []
|
|
241
|
+
for name in _extract_field_names(spec):
|
|
242
|
+
if name == "id":
|
|
243
|
+
findings.append(
|
|
244
|
+
Finding(
|
|
245
|
+
"DF-RESERVED-ID",
|
|
246
|
+
"medium",
|
|
247
|
+
"Field 'id' collides with the system Id primary key and is rejected; "
|
|
248
|
+
"use 'slug' (or another business-key name) instead.",
|
|
249
|
+
location=name,
|
|
250
|
+
entry_id="DF-RESERVED-ID",
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
if "_" in name:
|
|
254
|
+
findings.append(
|
|
255
|
+
Finding(
|
|
256
|
+
"DF-UNDERSCORE-DROP",
|
|
257
|
+
"high",
|
|
258
|
+
f"Field {name!r} contains an underscore and is silently dropped on "
|
|
259
|
+
f"insert (no error surfaced). Use {_to_camel(name)!r} instead.",
|
|
260
|
+
location=name,
|
|
261
|
+
entry_id="DF-UNDERSCORE-DROP",
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
return findings
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: maestro-case-kit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Agent-native knowledge layer + offline, credential-free static validators for UiPath Maestro Case footguns.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: agent-skills,case-management,linter,maestro,mcp,uipath
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# Maestro Case Kit
|
|
11
|
+
|
|
12
|
+
Offline, credential-free **knowledge + static validators** for UiPath **Maestro Case /
|
|
13
|
+
Data Fabric / Action Center** footguns. One define-once source, four artifacts: a Go-free
|
|
14
|
+
Python **CLI**, an **MCP server**, a Claude Code **skill**, and an OpenClaw **skill**.
|
|
15
|
+
|
|
16
|
+
> Built from behaviors discovered while running a multi-stakeholder crisis case end-to-end
|
|
17
|
+
> on UiPath Automation Cloud. The orchestration tier *above* the canvas is unserved by
|
|
18
|
+
> official tooling; this kit makes the hard-won knowledge installable and agent-native.
|
|
19
|
+
|
|
20
|
+
## Why
|
|
21
|
+
|
|
22
|
+
- UiPath's coding-agent MCP is a single catch-all `run_command` shell — not typed tools.
|
|
23
|
+
- Maestro Case error codes (`400300`, `160009`, `170015`, ...) return zero search results.
|
|
24
|
+
- Caseplan edits can be silently inert; Data Fabric fields can silently vanish on insert.
|
|
25
|
+
|
|
26
|
+
This kit encodes those footguns as a **version-stamped knowledge layer** + **CI linters**
|
|
27
|
+
that run with **no UiPath login**.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pipx install maestro-case-kit # CLI: maestro-case ; MCP: maestro-case-mcp
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Use
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
maestro-case explain 400300 # error code -> proven cause + fix (offline)
|
|
39
|
+
maestro-case lint path/to/caseplan-dir # static V20 lint (stale .bpmn, no start event, ...)
|
|
40
|
+
maestro-case check-spawn path/to/caseplan-dir # =datafabric.qem in spawn inputs (400300)
|
|
41
|
+
maestro-case check-df entity-spec.json # Data Fabric underscore-drop / reserved id
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Every command takes `--json` and exits non-zero when it has a finding, so it drops
|
|
45
|
+
straight into CI. See [`SKILL.md`](SKILL.md) for agent-host usage and recipes.
|
|
46
|
+
|
|
47
|
+
## MCP server
|
|
48
|
+
|
|
49
|
+
`maestro-case-mcp` speaks newline-delimited JSON-RPC over stdio (no third-party MCP SDK
|
|
50
|
+
dependency) and exposes typed tools: `maestro_case_explain`, `maestro_case_lint`,
|
|
51
|
+
`maestro_case_check_spawn`, `maestro_case_check_df`. Register it with any MCP host.
|
|
52
|
+
|
|
53
|
+
## One source, many harnesses
|
|
54
|
+
|
|
55
|
+
`SKILL.md` is the define-once skill source. Fan it out to other coding-agent runtimes
|
|
56
|
+
(Cursor, Codex, Gemini, Copilot, OpenClaw/ClawHub) with a skills converter — e.g.
|
|
57
|
+
`/polyskill` or `npx skills add`. The CLI and MCP server are generated from the same
|
|
58
|
+
shared tool registry (`maestro_case_kit/tools.py`), so a fix lands once and every surface
|
|
59
|
+
inherits it.
|
|
60
|
+
|
|
61
|
+
## Knowledge entries & contributions
|
|
62
|
+
|
|
63
|
+
Entries live in `maestro_case_kit/data/knowledge.json`, each stamped with the version it
|
|
64
|
+
was proven on and an optional `resolved_in`. Contributions run through an automated
|
|
65
|
+
IP-safety + schema gate (see `CONTRIBUTING.md`). License: MIT.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
maestro_case_kit/__init__.py,sha256=XRvRISS5KvcGp0qkmmWwgyPQx-GVdq0Qg3B8gT3-_Yo,394
|
|
2
|
+
maestro_case_kit/cli.py,sha256=7ZzoOHE7GkseM2ZBdy1IthowkU1btQVx6I2ykQHx_SE,6880
|
|
3
|
+
maestro_case_kit/contribution.py,sha256=LkRodpLoATWdtLmb2km9vcCrQmsY7jKwOSMPToZVZIo,3005
|
|
4
|
+
maestro_case_kit/knowledge.py,sha256=FWUN-samNj8HtkxWQzlJJhV9OJxcWoJNN9kKAV7Sq9U,4923
|
|
5
|
+
maestro_case_kit/mcp_server.py,sha256=-ajM2ujCfEv3rkh0JTjeTyDgvoUuRkGCHzjzsn8KTlw,3041
|
|
6
|
+
maestro_case_kit/tools.py,sha256=Ymuq7BgTQMOB4VGsHcjSADhDNrsHKAWGohhIzkklAhE,3190
|
|
7
|
+
maestro_case_kit/validators.py,sha256=7Y9_Nfc0-dnb5CwmfaOMZYnK4983ocXKkWHkllfkbbc,10005
|
|
8
|
+
maestro_case_kit/data/knowledge.json,sha256=hvu6Ey3XWVz0_8P9ebf3JulPGhmod652Bl90axJTqkg,10938
|
|
9
|
+
maestro_case_kit-0.1.0.dist-info/METADATA,sha256=16As3wzE6fAYJUjr4ieWMLYS1aDiWJ-Df4OPvgmUoWc,2868
|
|
10
|
+
maestro_case_kit-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
11
|
+
maestro_case_kit-0.1.0.dist-info/entry_points.txt,sha256=VBEKF4kwpfhvMQyTz-prBmszS6kM5rZfV44MbvIPrrM,111
|
|
12
|
+
maestro_case_kit-0.1.0.dist-info/RECORD,,
|